Skip to content

Commit 08ce711

Browse files
ryan-williamsclaude
andcommitted
Brush + pin elements via legend hover/click
Hovering an element pill in the Controls legend now dims non-matching atoms (and labels) to opacity 0.15. Clicking a pill *pins* the highlight, surviving subsequent hover changes — useful for inspecting one species while orbiting/scrubbing. UX details: - Container-level `onMouseLeave` (not per-item) closes the inter-pill gap so traversing between adjacent pills doesn't briefly reset to null and flicker all atoms back on. - Pin is cleared by *meaningless* clicks only: a non-drag mouseup that isn't on/from an interactive control. `mousedown`/`mouseup` deltas >5px count as drag (so orbit-rotating the canvas preserves pin); a selector list (`input, button, select, textarea, label, a, summary, [role=…], [contenteditable], [data-legend-item]`) shields real controls from triggering an unpin. - Pinned pill shows a colored border in its element color as a visual cue. - Atom labels now default-on (`al` is `boolTrueParam`) so brushing has legible legend-to-atom association out of the box. The dim factor multiplies through `atomOpacity`'s tile-fade, and labels share the same opacity, so periodic copies fade in concert with their primary-cell counterparts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e17784b commit 08ce711

4 files changed

Lines changed: 92 additions & 7 deletions

File tree

pkgs/core/src/components/Controls.tsx

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useEffect, useState } from 'react'
12
import { getElement } from '../utils/elements.ts'
23
import { densityToQuantile, quantileToDensity } from '../utils/color-ramp.ts'
34
import styles from './Controls.module.css'
@@ -59,6 +60,10 @@ interface ControlsProps {
5960
onTilePaddingChange?: (v: number) => void
6061
tileFade?: number
6162
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
6267
}
6368

6469
const AXIS_LABELS = ['X', 'Y', 'Z'] as const
@@ -128,21 +133,90 @@ export function Controls({
128133
onTilePaddingChange,
129134
tileFade,
130135
onTileFadeChange,
136+
highlightElement: _highlightElement,
137+
onHighlightElementChange,
131138
}: ControlsProps) {
132139
const tp = tilePadding ?? 0
133140
const hasTiling = tp > 0
134141

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+
135183
return (
136184
<div className={styles.controls}>
137185
<div className={styles.controlTitle}>{filename}</div>
138186
{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+
>
140191
{elements.map((el, i) => {
141192
const { color } = getElement(el)
142193
const css = `#${color.toString(16).padStart(6, '0')}`
143194
const count = counts?.[i]
195+
const isActive = active === el
196+
const isDimmed = active != null && !isActive
197+
const isPinned = pinned === el
144198
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+
>
146220
<span style={{
147221
width: 10,
148222
height: 10,

pkgs/core/src/components/CrystalStructure.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ interface CrystalStructureProps {
1919
tiles?: TileInfo[]
2020
tilePadding?: number
2121
tileFade?: number
22+
/** When set, atoms of this element render normally; others fade aggressively. */
23+
highlightElement?: string | null
2224
}
2325

2426
// Lattice vector colors — YOV (yellow, orange, violet), warm complement of gizmo RGB
@@ -108,7 +110,7 @@ function quantizeOpacity(o: number): number {
108110
return Math.round(o * 20) / 20
109111
}
110112

111-
export function CrystalStructure({ volume, showAtoms, showAtomLabels, showAbcCell, showXyzBox, dashedLines, lineWidth = 1, tiles, tilePadding = 0, tileFade = 1 }: CrystalStructureProps) {
113+
export function CrystalStructure({ volume, showAtoms, showAtomLabels, showAbcCell, showXyzBox, dashedLines, lineWidth = 1, tiles, tilePadding = 0, tileFade = 1, highlightElement }: CrystalStructureProps) {
112114
const { lattice, structure } = volume
113115

114116
// Group atoms by (element, quantized opacity) for tiled instanced rendering
@@ -128,7 +130,9 @@ export function CrystalStructure({ volume, showAtoms, showAtomLabels, showAbcCel
128130
atom.fracCoords[1] + tile.fracOffset[1],
129131
atom.fracCoords[2] + tile.fracOffset[2],
130132
]
131-
const opacity = tile.isPrimary ? 1 : atomOpacity(fracPos, tilePadding, tileFade)
133+
const dim = highlightElement != null && atom.element !== highlightElement ? 0.15 : 1
134+
const tileOp = tile.isPrimary ? 1 : atomOpacity(fracPos, tilePadding, tileFade)
135+
const opacity = tileOp * dim
132136
if (opacity <= 0) continue
133137

134138
const qo = quantizeOpacity(opacity)
@@ -147,7 +151,7 @@ export function CrystalStructure({ volume, showAtoms, showAtomLabels, showAbcCel
147151
}
148152
}
149153
return { atomGroups: groups, labelAtoms: labels }
150-
}, [lattice, structure, tiles, tilePadding, tileFade])
154+
}, [lattice, structure, tiles, tilePadding, tileFade, highlightElement])
151155

152156
const origin = useMemo(() => fracToCart(lattice, [0, 0, 0]), [lattice])
153157

pkgs/core/src/components/DensityViewer.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ interface DensityViewerProps {
7373
/** Override surface color/opacity (e.g. from density-quantile ramp). If null, renderers use defaults. */
7474
surfaceColor?: [number, number, number] | null
7575
surfaceOpacityOverride?: number | null
76+
/** When set, atoms of this element render normally; others fade aggressively. */
77+
highlightElement?: string | null
7678
}
7779

7880
export function DensityViewer({
@@ -103,6 +105,7 @@ export function DensityViewer({
103105
glbUrl,
104106
surfaceColor,
105107
surfaceOpacityOverride,
108+
highlightElement,
106109
}: DensityViewerProps) {
107110
const tiles = useMemo(() => {
108111
if (tilePadding <= 0) return undefined
@@ -149,7 +152,7 @@ export function DensityViewer({
149152
? <VolumeRenderer volume={volume} isoLevel={isoLevel} opacity={surfaceOpacityOverride ?? opacity} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} color={surfaceColor ?? undefined} />
150153
: <IsosurfaceRenderer volume={volume} isoLevel={isoLevel} opacity={surfaceOpacityOverride ?? opacity} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} color={surfaceColor ?? undefined} />
151154
}
152-
<CrystalStructure volume={volume} showAtoms={showAtoms} showAtomLabels={showAtomLabels} showAbcCell={showAbcCell} showXyzBox={showXyzBox} dashedLines={dashedLines} lineWidth={lineWidth} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} />
155+
<CrystalStructure volume={volume} showAtoms={showAtoms} showAtomLabels={showAtomLabels} showAbcCell={showAbcCell} showXyzBox={showXyzBox} dashedLines={dashedLines} lineWidth={lineWidth} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} highlightElement={highlightElement} />
153156
{showSlice && sliceAxis !== undefined && sliceIndex !== undefined && (
154157
<SlicePlane3D lattice={volume.lattice} axis={sliceAxis} sliceIndex={sliceIndex} dims={volume.grid.dims} data={volume.grid.data} padding={tilePadding} fade={tileFade} />
155158
)}

pkgs/static/src/App.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ export default function App() {
203203
const [showAtoms, setShowAtoms] = useUrlState('ha', boolTrueParam)
204204
const [showAbcCell, setShowAbcCell] = useUrlState('hc', boolTrueParam)
205205
const [showXyzBox, setShowXyzBox] = useUrlState('xb', boolParam)
206-
const [showAtomLabels, setShowAtomLabels] = useUrlState('al', boolParam)
206+
const [showAtomLabels, setShowAtomLabels] = useUrlState('al', boolTrueParam)
207207
const [dashedLines, setDashedLines] = useUrlState('dl', boolParam)
208208
const [showSlice, setShowSlice] = useUrlState('sl', boolTrueParam)
209209
const [sliceAxis, setSliceAxis] = useUrlState('sa', intParam(2)) as [0 | 1 | 2, (v: 0 | 1 | 2) => void]
@@ -239,6 +239,7 @@ export default function App() {
239239
})
240240
const [urlLoading, setUrlLoading] = useState(false)
241241
const [fetchStatus, setFetchStatus] = useState<string | null>(null)
242+
const [highlightElement, setHighlightElement] = useState<string | null>(null)
242243
const [awsModalOpen, setAwsModalOpen] = useState(false)
243244
const [browseOpen, setBrowseOpen] = useState(false)
244245
const [awsCreds, setAwsCreds] = useState<AWSCredentials | null>(loadCredentials)
@@ -1399,6 +1400,7 @@ export default function App() {
13991400
glbUrl={glbUrl}
14001401
surfaceColor={sampledColor?.color ?? null}
14011402
surfaceOpacityOverride={sampledColor?.opacity ?? null}
1403+
highlightElement={highlightElement}
14021404
/>
14031405
)}
14041406
{showSlice && (() => {
@@ -1529,6 +1531,8 @@ export default function App() {
15291531
onTilePaddingChange={setTilePadding}
15301532
tileFade={tileFade}
15311533
onTileFadeChange={setTileFade}
1534+
highlightElement={highlightElement}
1535+
onHighlightElementChange={setHighlightElement}
15321536
/>
15331537
</ErrorBoundary>
15341538
)}

0 commit comments

Comments
 (0)