feat(web): гистограммы каналов в редакторе кривой

График тональной кривой теперь показывает гистограмму фото: выбранный канал —
своим цветом, мастер-канал (RGB) — всеми тремя слоями. Гистограмма считается
в браузере по уменьшенной копии базового превью и не влияет на скорость
перерисовки.
This commit is contained in:
iu
2026-10-21 01:29:35 +03:00
parent d904b2fe31
commit b64574b921
2 changed files with 89 additions and 4 deletions
@@ -53,6 +53,80 @@
};
});
// Гистограмма каналов считается по базовому превью фото.
type Histogram = { red: number[]; green: number[]; blue: number[] };
let histogram = $state<Histogram | null>(null);
$effect(() => {
const url = photo.preview_url;
if (!url) {
histogram = null;
return;
}
let cancelled = false;
const img = new Image();
img.onload = () => {
if (cancelled) return;
histogram = computeHistogram(img);
};
img.src = url;
return () => {
cancelled = true;
};
});
/** Считает гистограмму каналов по уменьшенной копии фото. */
function computeHistogram(img: HTMLImageElement): Histogram | null {
const width = 256;
const height = Math.max(1, Math.round((img.naturalHeight / img.naturalWidth) * width));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(img, 0, 0, width, height);
const data = ctx.getImageData(0, 0, width, height).data;
const r = new Array<number>(256).fill(0);
const g = new Array<number>(256).fill(0);
const b = new Array<number>(256).fill(0);
for (let i = 0; i < data.length; i += 4) {
r[data[i]]++;
g[data[i + 1]]++;
b[data[i + 2]]++;
}
let max = 0;
for (const values of [r, g, b]) {
for (const v of values) if (v > max) max = v;
}
if (!max) return null;
const norm = (values: number[]) => values.map((v) => v / max);
return { red: norm(r), green: norm(g), blue: norm(b) };
}
/** Каналы, которые показываем на графике: у мастер-канала — все три. */
const visibleChannels = $derived<CurveChannel[]>(
channel === 'rgb' ? ['red', 'green', 'blue'] : [channel]
);
/** Значения гистограммы для канала. */
function histogramValues(ch: CurveChannel): number[] {
if (!histogram) return [];
if (ch === 'rgb') return histogram.red;
return histogram[ch];
}
/** Гистограмма канала в виде вертикальных столбиков. */
function histogramPath(values: number[]): string {
const parts: string[] = [];
const last = values.length - 1;
for (let i = 0; i <= last; i++) {
const x = ((i / last) * 100).toFixed(2);
const y = (100 - values[i] * 100).toFixed(2);
parts.push(`M${x},100 L${x},${y}`);
}
return parts.join(' ');
}
// Правки уходят на сервер не сразу: пока пользователь крутит ползунки,
// фото перерисовывает браузер, а серверу достаётся уже settled-версия.
const SAVE_DELAY = 10_000;
@@ -347,6 +421,16 @@
onpointerleave={onPointUp}
>
<rect x="0" y="0" width="100" height="100" class="bg-bg" />
{#if histogram}
{#each visibleChannels as ch (ch)}
<path
d={histogramPath(histogramValues(ch))}
fill="none"
stroke-width="0.4"
style="stroke: {CHANNEL_COLORS[ch]}; opacity: {ch === 'rgb' ? 0.45 : 0.7}"
/>
{/each}
{/if}
{#each [25, 50, 75] as step (step)}
<line x1={step} y1="0" x2={step} y2="100" class="stroke-border" stroke-width="0.4" />
<line x1="0" y1={step} x2="100" y2={step} class="stroke-border" stroke-width="0.4" />