immich-automated-selfie-tim.../frontend/src/lib/components/visual/BrightnessIndicator.svelte
2026-02-11 22:17:26 +01:00

261 lines
6.2 KiB
Svelte

<script>
/**
* Interactive brightness filter control.
* Displays a gradient from black to white with draggable markers.
*
* @param {number} minBrightness - Minimum accepted brightness (0-1)
* @param {number} maxBrightness - Maximum accepted brightness (0-1)
*/
let { minBrightness = $bindable(0.1), maxBrightness = $bindable(0.95) } = $props();
const width = 240;
const height = 80;
const gradientBarHeight = 30;
const gradientBarY = 15;
// Convert brightness values (0-1) to x positions
const minX = $derived(minBrightness * width);
const maxX = $derived(maxBrightness * width);
// Unique ID for SVG gradient to avoid collision with multiple instances
const gradientId = `brightness-gradient-${Math.random().toString(36).slice(2, 9)}`;
let dragging = $state(null); // 'min' | 'max' | null
let svgElement = $state(null);
function handlePointerDown(e, type) {
e.preventDefault();
dragging = type;
svgElement.setPointerCapture(e.pointerId);
}
function handlePointerMove(e) {
if (!dragging || !svgElement) return;
const rect = svgElement.getBoundingClientRect();
const x = e.clientX - rect.left;
const scaleX = width / rect.width;
const actualX = x * scaleX;
// Clamp to valid range
let newValue = Math.max(0, Math.min(1, actualX / width));
// Round to nearest 0.05
newValue = Math.round(newValue * 20) / 20;
if (dragging === 'min') {
// Ensure min doesn't exceed max - 0.1
minBrightness = Math.min(newValue, maxBrightness - 0.1);
} else if (dragging === 'max') {
// Ensure max doesn't go below min + 0.1
maxBrightness = Math.max(newValue, minBrightness + 0.1);
}
}
function handlePointerUp(e) {
if (dragging && svgElement) {
svgElement.releasePointerCapture(e.pointerId);
}
dragging = null;
}
</script>
<svg
bind:this={svgElement}
viewBox="0 0 {width} {height}"
class="brightness-indicator"
class:dragging={dragging !== null}
role="group"
aria-label="Brightness range control"
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointerleave={handlePointerUp}
>
<!-- Define gradient from black to white -->
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:rgb(0,0,0);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgb(255,255,255);stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background gradient bar -->
<rect
x="0"
y={gradientBarY}
width={width}
height={gradientBarHeight}
fill="url(#{gradientId})"
stroke="white"
stroke-width="1.5"
rx="3"
/>
<!-- Rejected range overlays (darker areas) -->
<!-- Left rejected area (too dark) -->
<rect
x="0"
y={gradientBarY}
width={minX}
height={gradientBarHeight}
fill="rgba(255, 0, 0, 0.3)"
class="rejected-area"
/>
<!-- Right rejected area (too bright) -->
<rect
x={maxX}
y={gradientBarY}
width={width - maxX}
height={gradientBarHeight}
fill="rgba(255, 0, 0, 0.3)"
class="rejected-area"
/>
<!-- Min brightness marker -->
<g
class="marker"
class:active={dragging === 'min'}
onpointerdown={(e) => handlePointerDown(e, 'min')}
role="button"
tabindex="0"
>
<!-- Invisible wider hit area for easier grabbing -->
<rect
x={minX - 10}
y={gradientBarY - 10}
width="20"
height={gradientBarHeight + 20}
fill="transparent"
class="hit-area"
/>
<!-- Vertical line -->
<line
x1={minX}
y1={gradientBarY - 5}
x2={minX}
y2={gradientBarY + gradientBarHeight + 5}
stroke="#4f46e5"
stroke-width="2.5"
stroke-linecap="round"
/>
<!-- Triangle at top -->
<path
d="M {minX - 4} {gradientBarY - 5} L {minX + 4} {gradientBarY - 5} L {minX} {gradientBarY - 1} Z"
fill="#4f46e5"
/>
<!-- Triangle at bottom -->
<path
d="M {minX - 4} {gradientBarY + gradientBarHeight + 5} L {minX + 4} {gradientBarY + gradientBarHeight + 5} L {minX} {gradientBarY + gradientBarHeight + 1} Z"
fill="#4f46e5"
/>
</g>
<!-- Max brightness marker -->
<g
class="marker"
class:active={dragging === 'max'}
onpointerdown={(e) => handlePointerDown(e, 'max')}
role="button"
tabindex="0"
>
<!-- Invisible wider hit area for easier grabbing -->
<rect
x={maxX - 10}
y={gradientBarY - 10}
width="20"
height={gradientBarHeight + 20}
fill="transparent"
class="hit-area"
/>
<!-- Vertical line -->
<line
x1={maxX}
y1={gradientBarY - 5}
x2={maxX}
y2={gradientBarY + gradientBarHeight + 5}
stroke="#4f46e5"
stroke-width="2.5"
stroke-linecap="round"
/>
<!-- Triangle at top -->
<path
d="M {maxX - 4} {gradientBarY - 5} L {maxX + 4} {gradientBarY - 5} L {maxX} {gradientBarY - 1} Z"
fill="#4f46e5"
/>
<!-- Triangle at bottom -->
<path
d="M {maxX - 4} {gradientBarY + gradientBarHeight + 5} L {maxX + 4} {gradientBarY + gradientBarHeight + 5} L {maxX} {gradientBarY + gradientBarHeight + 1} Z"
fill="#4f46e5"
/>
</g>
<!-- Value labels -->
<text
x={minX}
y={gradientBarY + gradientBarHeight + 20}
text-anchor="middle"
class="value-label"
>
{minBrightness.toFixed(2)}
</text>
<text
x={maxX}
y={gradientBarY + gradientBarHeight + 20}
text-anchor="middle"
class="value-label"
>
{maxBrightness.toFixed(2)}
</text>
</svg>
<style>
.brightness-indicator {
width: 240px;
height: 80px;
touch-action: none;
user-select: none;
}
.brightness-indicator.dragging {
cursor: grabbing;
}
.marker {
cursor: grab;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.marker:hover line,
.marker:hover path {
stroke: #6366f1;
fill: #6366f1;
}
.marker.active {
cursor: grabbing;
}
.marker.active line,
.marker.active path {
stroke: #818cf8;
fill: #818cf8;
}
.hit-area {
cursor: grab;
}
.rejected-area {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.value-label {
fill: #4f46e5;
font-size: 14px;
font-weight: 600;
font-family: monospace;
pointer-events: none;
user-select: none;
}
</style>