import { bindGlobalStyle, getGlobalStylesId, CssProps, RefProps, VNode } from 'lupine.web'; import { SvgSvg, SvgPath, SvgLine, SvgText, SvgCircle, SvgG } from '../svg-props'; export type GaugeHighlightRange = { start: number; end: number; color?: string; }; export type GaugeHookProps = { setValue?: (val: number | [number, number]) => void; getValue?: () => number | [number, number]; }; export type GaugeProps = { class?: string; style?: CssProps; min?: number; max?: number; step?: number; value?: number | [number, number]; disabled?: boolean; readonly?: boolean; showTicks?: boolean; showTickLabels?: boolean; showActiveTrack?: boolean; activeTrackColor?: string; tickStep?: number; range?: boolean; highlights?: GaugeHighlightRange[]; onChange?: (val: number | [number, number]) => void; hook?: GaugeHookProps; }; const gaugeCss: CssProps = { position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', userSelect: 'none', '&.disabled': { opacity: 'var(--primary-disabled-opacity, 0.5)', pointerEvents: 'none', }, '.&-svg': { width: '100%', height: 'auto', maxWidth: '300px', overflow: 'visible', cursor: 'pointer', touchAction: 'none', // Prevent scrolling on touch devices while dragging }, '&.readonly .&-svg': { cursor: 'default', }, '.&-track': { fill: 'none', stroke: 'var(--secondary-bg-color, #eee)', strokeWidth: 10, strokeLinecap: 'round', }, '.&-active-track': { fill: 'none', /* We'll set stroke dynamically via style mapping to activeTrackColor */ strokeWidth: 16, strokeLinecap: 'round', opacity: 0.5, }, '.&-highlight': { fill: 'none', strokeWidth: 10, strokeLinecap: 'round', }, '.&-needle': { fill: 'var(--primary-accent-color, #0a74c9)', filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.3))', cursor: 'grab', transition: 'transform 0.1s ease-out', }, '.&-needle:active': { cursor: 'grabbing', }, '.&-tick-mark': { stroke: 'var(--secondary-border-color, #a0a0a0)', strokeWidth: 2, }, '.&-tick-label': { fill: 'var(--secondary-color, #818181)', fontSize: '10px', textAnchor: 'middle', dominantBaseline: 'hanging', // Hangs below the anchor pointerEvents: 'none', }, '.&-value-display': { marginTop: '-10px', fontWeight: 'bold', fontSize: '18px', color: 'var(--primary-text-color, #000)', textAlign: 'center', }, }; // Helper for SVG Arc function polarToCartesian(centerX: number, centerY: number, radius: number, angleInDegrees: number) { // 180 deg is Left, 0/360 is Right. Up is 270. // In SVG, Top is 0. So Up is -90. Left is 180. Right is 0. // To match standard polar coordinates where 0 is left and 180 is right: const angleInRadians = (angleInDegrees + 180) * (Math.PI / 180.0); return { x: centerX + radius * Math.cos(angleInRadians), y: centerY + radius * Math.sin(angleInRadians), }; } function describeArc(x: number, y: number, radius: number, startAngle: number, endAngle: number) { // To avoid zero length arcs failing to render if (Math.abs(endAngle - startAngle) < 0.1) return ''; const start = polarToCartesian(x, y, radius, endAngle); const end = polarToCartesian(x, y, radius, startAngle); // for semi-circle we don't need largeArcFlag usually, since max is 180. const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1'; return ['M', start.x, start.y, 'A', radius, radius, 0, largeArcFlag, 0, end.x, end.y].join(' '); } export const Gauge = (props: GaugeProps) => { const min = props.min ?? 0; const max = props.max ?? 100; const step = props.step ?? 1; const isRange = !!props.range; const isReadonly = !!props.readonly; const showActiveTrack = props.showActiveTrack !== false; // Default true const activeColor = props.activeTrackColor || 'var(--success-color, #2ecc71)'; const globalCssId = getGlobalStylesId(gaugeCss); bindGlobalStyle(globalCssId, gaugeCss); let val1 = 0; let val2 = 100; if (isRange) { if (Array.isArray(props.value)) { val1 = props.value[0]; val2 = props.value[1]; } else { val1 = min; val2 = max; } if (val1 > val2) { const temp = val1; val1 = val2; val2 = temp; } } else { val1 = (props.value as number) ?? min; } let currentVal1 = val1; let currentVal2 = val2; let mv = false; const getPercent = (v: number) => { return Math.max(0, Math.min(1, (v - min) / (max - min))); }; const updateVisuals = () => { const needle1 = ref.$('.needle-1') as SVGElement; const needle2 = ref.$('.needle-2') as SVGElement; const valDisplay = ref.$('.&-value-display') as HTMLElement; if (needle1) { const angle = getPercent(currentVal1) * 180; needle1.setAttribute('transform', `translate(100, 100) rotate(${angle})`); } if (isRange && needle2) { const angle = getPercent(currentVal2) * 180; needle2.setAttribute('transform', `translate(100, 100) rotate(${angle})`); } const activeTrack = ref.$('.&-active-track') as SVGPathElement; if (activeTrack) { const startP = getPercent(isRange ? currentVal1 : min) * 180; const endP = getPercent(isRange ? currentVal2 : currentVal1) * 180; const d = describeArc(100, 100, 80, startP, endP); activeTrack.setAttribute('d', d); } if (valDisplay) { valDisplay.innerText = isRange ? `${currentVal1} - ${currentVal2}` : `${currentVal1}`; } }; let draggingNeedle: 1 | 2 | null = null; const onPointerDown = (ev: PointerEvent) => { if (props.disabled || isReadonly) return; const svg = ref.$('.&-svg') as SVGSVGElement; if (!svg) return; svg.setPointerCapture(ev.pointerId); mv = true; updatePosition(ev.clientX, ev.clientY); }; const onPointerMove = (ev: PointerEvent) => { if (!mv) return; updatePosition(ev.clientX, ev.clientY); }; const onPointerUp = (ev: PointerEvent) => { const svg = ref.$('.&-svg') as SVGSVGElement; if (svg) { svg.releasePointerCapture(ev.pointerId); } mv = false; draggingNeedle = null; if (props.onChange) { props.onChange(isRange ? [currentVal1, currentVal2] : currentVal1); } }; const updatePosition = (cx: number, cy: number) => { const svg = ref.$('.&-svg') as SVGSVGElement; if (!svg) return; const rect = svg.getBoundingClientRect(); const scaleX = rect.width / 200; const scaleY = rect.height / 120; const centerX = rect.left + 100 * scaleX; const centerY = rect.top + 100 * scaleY; const dx = cx - centerX; const dy = cy - centerY; let angle = Math.atan2(dy, dx); if (angle >= 0) { if (angle > Math.PI / 2) angle = -Math.PI; else angle = 0; } const percent = angle / Math.PI + 1; let val = min + percent * (max - min); if (step > 0) { val = Math.round((val - min) / step) * step + min; } val = Math.max(min, Math.min(max, val)); if (isRange) { if (!draggingNeedle) { const dist1 = Math.abs(val - currentVal1); const dist2 = Math.abs(val - currentVal2); if (dist1 <= dist2) draggingNeedle = 1; else draggingNeedle = 2; } if (draggingNeedle === 1) { currentVal1 = Math.min(val, currentVal2); } else { currentVal2 = Math.max(val, currentVal1); } } else { currentVal1 = val; } updateVisuals(); }; if (props.hook) { props.hook.setValue = (val) => { if (isRange && Array.isArray(val)) { currentVal1 = Math.min(Math.max(val[0], min), max); currentVal2 = Math.max(Math.min(val[1], max), currentVal1); } else if (!isRange && typeof val === 'number') { currentVal1 = Math.min(Math.max(val, min), max); } updateVisuals(); }; props.hook.getValue = () => (isRange ? [currentVal1, currentVal2] : currentVal1); } const ref: RefProps & { onUnload?: (el: Element) => Promise } = { referToCssId: globalCssId, onLoad: async () => { updateVisuals(); }, onUnload: async (el: Element) => { return Promise.resolve(); }, }; const ticks: VNode[] = []; if (props.showTicks || props.showTickLabels) { const tStep = props.tickStep || max - min; const tickValues = []; for (let i = min; i <= max; i += tStep) { tickValues.push(i); } if (tickValues[tickValues.length - 1] !== max) { tickValues.push(max); } tickValues.forEach((t) => { const p = getPercent(t); const angle = p * 180; // 0 to 180 if (props.showTicks) { const start = polarToCartesian(100, 100, 72, angle); const end = polarToCartesian(100, 100, 65, angle); ticks.push(); } if (props.showTickLabels) { // Push labels slightly further in const pos = polarToCartesian(100, 100, 52, angle); ticks.push( {t} ); } }); } const highlightPaths = (props.highlights || []).map((h) => { const startP = getPercent(h.start) * 180; const endP = getPercent(h.end) * 180; const d = describeArc(100, 100, 80, startP, endP); return ; }); return (
{/* Active Track (filled portion) - Placed first so it renders behind the base track and needles */} {showActiveTrack && } {/* Base Track */} {/* Highlight Ranges */} {highlightPaths} {/* Ticks */} {ticks} {/* Needle 1 */} {/* Needle path at angle 0 (points Left) */} {!isRange ? ( ) : ( <> )}
{isRange ? `${currentVal1} - ${currentVal2}` : currentVal1}
); };