|
| 1 | +import { useEffect, useState } from 'react' |
1 | 2 | import { getElement } from '../utils/elements.ts' |
2 | 3 | import { densityToQuantile, quantileToDensity } from '../utils/color-ramp.ts' |
3 | 4 | import styles from './Controls.module.css' |
@@ -59,6 +60,10 @@ interface ControlsProps { |
59 | 60 | onTilePaddingChange?: (v: number) => void |
60 | 61 | tileFade?: number |
61 | 62 | onTileFadeChange?: (v: number) => void |
| 63 | + /** Element symbol currently hovered in the legend (controlled by parent) */ |
| 64 | + highlightElement?: string | null |
| 65 | + /** Called when the user hovers/unhovers an element pill in the legend */ |
| 66 | + onHighlightElementChange?: (el: string | null) => void |
62 | 67 | } |
63 | 68 |
|
64 | 69 | const AXIS_LABELS = ['X', 'Y', 'Z'] as const |
@@ -128,21 +133,90 @@ export function Controls({ |
128 | 133 | onTilePaddingChange, |
129 | 134 | tileFade, |
130 | 135 | onTileFadeChange, |
| 136 | + highlightElement: _highlightElement, |
| 137 | + onHighlightElementChange, |
131 | 138 | }: ControlsProps) { |
132 | 139 | const tp = tilePadding ?? 0 |
133 | 140 | const hasTiling = tp > 0 |
134 | 141 |
|
| 142 | + // Element legend brushing: pin via click (sticky), hover otherwise. |
| 143 | + const [pinned, setPinned] = useState<string | null>(null) |
| 144 | + const [hovered, setHovered] = useState<string | null>(null) |
| 145 | + const active = pinned ?? hovered |
| 146 | + useEffect(() => { |
| 147 | + onHighlightElementChange?.(active) |
| 148 | + }, [active, onHighlightElementChange]) |
| 149 | + |
| 150 | + // Only "meaningless" clicks (empty space, no drag, not on an interactive control) unpin. |
| 151 | + useEffect(() => { |
| 152 | + if (!pinned) return |
| 153 | + const INTERACTIVE = [ |
| 154 | + 'input', 'button', 'select', 'textarea', 'label', 'a', 'summary', |
| 155 | + '[role="button"]', '[role="checkbox"]', '[role="slider"]', '[role="link"]', |
| 156 | + '[role="menuitem"]', '[role="tab"]', '[role="switch"]', '[role="combobox"]', |
| 157 | + '[contenteditable=""]', '[contenteditable="true"]', |
| 158 | + '[data-legend-item]', |
| 159 | + ].join(',') |
| 160 | + let downX = 0 |
| 161 | + let downY = 0 |
| 162 | + let downInteractive = false |
| 163 | + const onDown = (e: MouseEvent) => { |
| 164 | + downX = e.clientX |
| 165 | + downY = e.clientY |
| 166 | + downInteractive = !!(e.target as Element | null)?.closest(INTERACTIVE) |
| 167 | + } |
| 168 | + const onUp = (e: MouseEvent) => { |
| 169 | + // Drag (>5px movement) → preserve pin |
| 170 | + if (Math.hypot(e.clientX - downX, e.clientY - downY) > 5) return |
| 171 | + if (downInteractive) return |
| 172 | + if ((e.target as Element | null)?.closest(INTERACTIVE)) return |
| 173 | + setPinned(null) |
| 174 | + } |
| 175 | + document.addEventListener('mousedown', onDown) |
| 176 | + document.addEventListener('mouseup', onUp) |
| 177 | + return () => { |
| 178 | + document.removeEventListener('mousedown', onDown) |
| 179 | + document.removeEventListener('mouseup', onUp) |
| 180 | + } |
| 181 | + }, [pinned]) |
| 182 | + |
135 | 183 | return ( |
136 | 184 | <div className={styles.controls}> |
137 | 185 | <div className={styles.controlTitle}>{filename}</div> |
138 | 186 | {elements && elements.length > 0 && ( |
139 | | - <div style={{ display: 'flex', gap: 8, padding: '2px 0 6px', flexWrap: 'wrap' }}> |
| 187 | + <div |
| 188 | + style={{ display: 'flex', gap: 8, padding: '2px 0 6px', flexWrap: 'wrap' }} |
| 189 | + onMouseLeave={() => setHovered(null)} |
| 190 | + > |
140 | 191 | {elements.map((el, i) => { |
141 | 192 | const { color } = getElement(el) |
142 | 193 | const css = `#${color.toString(16).padStart(6, '0')}` |
143 | 194 | const count = counts?.[i] |
| 195 | + const isActive = active === el |
| 196 | + const isDimmed = active != null && !isActive |
| 197 | + const isPinned = pinned === el |
144 | 198 | return ( |
145 | | - <span key={el} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: '#ccc' }}> |
| 199 | + <span |
| 200 | + key={el} |
| 201 | + data-legend-item="" |
| 202 | + onMouseEnter={() => setHovered(el)} |
| 203 | + onClick={() => setPinned(p => p === el ? null : el)} |
| 204 | + style={{ |
| 205 | + display: 'flex', |
| 206 | + alignItems: 'center', |
| 207 | + gap: 4, |
| 208 | + fontSize: 12, |
| 209 | + color: '#ccc', |
| 210 | + cursor: 'pointer', |
| 211 | + opacity: isDimmed ? 0.4 : 1, |
| 212 | + fontWeight: isActive ? 600 : 400, |
| 213 | + transition: 'opacity 120ms, font-weight 120ms', |
| 214 | + padding: '2px 6px', |
| 215 | + borderRadius: 4, |
| 216 | + border: isPinned ? `1px solid ${css}` : '1px solid transparent', |
| 217 | + userSelect: 'none', |
| 218 | + }} |
| 219 | + > |
146 | 220 | <span style={{ |
147 | 221 | width: 10, |
148 | 222 | height: 10, |
|
0 commit comments