Make the bridge readable in dark mode, and movable
The chart's text and rules were hex literals, so in dark mode the value labels stayed dark grey on the dark panel. They now follow the theme. The panel drags by its header and is no longer modal: the backdrop passes clicks through, so the pivot stays usable while the bridge is open, and Escape closes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
093b1473ab
commit
f9e989f20e
@ -15,9 +15,15 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
|||||||
const UP = '#2a78d6' // increase
|
const UP = '#2a78d6' // increase
|
||||||
const DOWN = '#e34948' // decrease
|
const DOWN = '#e34948' // decrease
|
||||||
const ANCHOR = '#6b7280' // baseline / current — neutral, 4.83:1 on white
|
const ANCHOR = '#6b7280' // baseline / current — neutral, 4.83:1 on white
|
||||||
const GRID = '#e5e7eb'
|
// Everything that is not a bar follows the theme. These were hex literals, so in
|
||||||
const INK = '#374151'
|
// dark mode the value labels stayed #374151 on a #2a2c2f panel and could not be
|
||||||
const INK_DIM = '#6b7280'
|
// read. Applied through `style` rather than the presentation attribute, where
|
||||||
|
// var() is not reliable across browsers.
|
||||||
|
const GRID = 'var(--border-color)'
|
||||||
|
const AXIS = 'var(--chart-axis)'
|
||||||
|
const INK = 'var(--text-secondary)'
|
||||||
|
const INK_DIM = 'var(--chart-ink-dim)'
|
||||||
|
const PANEL = 'var(--bg-secondary)' // the bars' separating stroke
|
||||||
|
|
||||||
const fmt = (n, dp = 2) =>
|
const fmt = (n, dp = 2) =>
|
||||||
n == null || !isFinite(n) ? '—'
|
n == null || !isFinite(n) ? '—'
|
||||||
@ -294,6 +300,10 @@ export default function BridgeView({
|
|||||||
const [hover, setHover] = useState(null)
|
const [hover, setHover] = useState(null)
|
||||||
const [width, setWidth] = useState(880)
|
const [width, setWidth] = useState(880)
|
||||||
const boxRef = useRef(null)
|
const boxRef = useRef(null)
|
||||||
|
// Where the panel has been dragged to, as an offset from centre. It opens
|
||||||
|
// centred each time rather than wherever it was last left.
|
||||||
|
const [pos, setPos] = useState({ x: 0, y: 0 })
|
||||||
|
const dragRef = useRef(null)
|
||||||
|
|
||||||
// Build the steps: baseline anchor, one floating step per tag, current anchor.
|
// Build the steps: baseline anchor, one floating step per tag, current anchor.
|
||||||
const compute = useCallback(async () => {
|
const compute = useCallback(async () => {
|
||||||
@ -405,6 +415,36 @@ export default function BridgeView({
|
|||||||
useEffect(() => { try { localStorage.setItem('pf_bridge_basis', basis) } catch {} }, [basis])
|
useEffect(() => { try { localStorage.setItem('pf_bridge_basis', basis) } catch {} }, [basis])
|
||||||
useEffect(() => { if (open) compute() }, [open, compute])
|
useEffect(() => { if (open) compute() }, [open, compute])
|
||||||
|
|
||||||
|
useEffect(() => { if (open) setPos({ x: 0, y: 0 }) }, [open])
|
||||||
|
|
||||||
|
// Escape closes it. There is no backdrop to click any more -- see below.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
// Drag by the header. Pointer capture keeps the drag alive when the cursor
|
||||||
|
// outruns the panel; the close button is excluded so clicking it still closes.
|
||||||
|
const startDrag = (e) => {
|
||||||
|
if (e.button !== 0 || e.target.closest('button')) return
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
dragRef.current = { px: e.clientX, py: e.clientY, x: pos.x, y: pos.y }
|
||||||
|
}
|
||||||
|
const moveDrag = (e) => {
|
||||||
|
const d = dragRef.current
|
||||||
|
if (!d) return
|
||||||
|
// keep the header on screen, or there is nothing left to drag it back by
|
||||||
|
const maxX = window.innerWidth / 2 - 40
|
||||||
|
const maxY = window.innerHeight / 2 - 20
|
||||||
|
setPos({
|
||||||
|
x: Math.max(-maxX, Math.min(maxX, d.x + e.clientX - d.px)),
|
||||||
|
y: Math.max(-maxY, Math.min(maxY, d.y + e.clientY - d.py)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const endDrag = () => { dragRef.current = null }
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !boxRef.current) return
|
if (!open || !boxRef.current) return
|
||||||
const ro = new ResizeObserver(([e]) => setWidth(Math.max(420, e.contentRect.width)))
|
const ro = new ResizeObserver(([e]) => setWidth(Math.max(420, e.contentRect.width)))
|
||||||
@ -421,11 +461,18 @@ export default function BridgeView({
|
|||||||
const ticks = niceTicks(yMin, yMax, 5)
|
const ticks = niceTicks(yMin, yMax, 5)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
// Not modal. The overlay passes clicks through, so the pivot stays usable
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-5xl mx-4 flex flex-col max-h-[88vh]"
|
// with the bridge open -- moving it aside to read the grid under it, or
|
||||||
onClick={e => e.stopPropagation()}>
|
// changing the selection it is scoped to, which recomputes it. It used to
|
||||||
|
// be a dimmed backdrop that closed on click, which made the panel immovable
|
||||||
|
// and the pivot unreachable until it was dismissed.
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="pointer-events-auto bg-white rounded-lg shadow-2xl border border-gray-200 w-full max-w-5xl mx-4 flex flex-col max-h-[88vh]"
|
||||||
|
style={{ transform: `translate(${pos.x}px, ${pos.y}px)` }}>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0 cursor-move select-none touch-none"
|
||||||
|
onPointerDown={startDrag} onPointerMove={moveDrag}
|
||||||
|
onPointerUp={endDrag} onPointerCancel={endDrag}>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="font-medium text-gray-700 text-sm">Bridge</span>
|
<span className="font-medium text-gray-700 text-sm">Bridge</span>
|
||||||
{versionName && <span className="text-gray-600 text-xs">{versionName}</span>}
|
{versionName && <span className="text-gray-600 text-xs">{versionName}</span>}
|
||||||
@ -512,8 +559,8 @@ export default function BridgeView({
|
|||||||
{ticks.map(t => (
|
{ticks.map(t => (
|
||||||
<g key={t}>
|
<g key={t}>
|
||||||
<line x1={PAD.l} x2={PAD.l + plotW} y1={y(t)} y2={y(t)}
|
<line x1={PAD.l} x2={PAD.l + plotW} y1={y(t)} y2={y(t)}
|
||||||
stroke={t === 0 ? '#d1d5db' : GRID} strokeWidth={t === 0 ? 1.5 : 1} />
|
style={{ stroke: t === 0 ? AXIS : GRID }} strokeWidth={t === 0 ? 1.5 : 1} />
|
||||||
<text x={PAD.l - 8} y={y(t) + 3} textAnchor="end" fontSize="10" fill={INK_DIM}>
|
<text x={PAD.l - 8} y={y(t) + 3} textAnchor="end" fontSize="10" style={{ fill: INK_DIM }}>
|
||||||
{fmtAxis(t)}
|
{fmtAxis(t)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@ -537,19 +584,19 @@ export default function BridgeView({
|
|||||||
{/* connector to the next bar, drawn behind */}
|
{/* connector to the next bar, drawn behind */}
|
||||||
{i < steps.length - 1 && (
|
{i < steps.length - 1 && (
|
||||||
<line x1={x + barW} x2={xOf(i + 1)} y1={y(s.end)} y2={y(s.end)}
|
<line x1={x + barW} x2={xOf(i + 1)} y1={y(s.end)} y2={y(s.end)}
|
||||||
stroke="#cbd5e1" strokeWidth="1" strokeDasharray="2 2" />
|
style={{ stroke: AXIS }} strokeWidth="1" strokeDasharray="2 2" />
|
||||||
)}
|
)}
|
||||||
{/* hit target larger than the mark */}
|
{/* hit target larger than the mark */}
|
||||||
<rect x={x - 6} y={PAD.t} width={barW + 12} height={plotH} fill="transparent" />
|
<rect x={x - 6} y={PAD.t} width={barW + 12} height={plotH} fill="transparent" />
|
||||||
<rect x={x} y={top} width={barW} height={h} rx="4" fill={fill}
|
<rect x={x} y={top} width={barW} height={h} rx="4" fill={fill}
|
||||||
opacity={on ? 1 : 0.92}
|
opacity={on ? 1 : 0.92}
|
||||||
stroke="#ffffff" strokeWidth="2" />
|
style={{ stroke: PANEL }} strokeWidth="2" />
|
||||||
{/* direct label: few bars, so every one is labelled */}
|
{/* direct label: few bars, so every one is labelled */}
|
||||||
<text x={x + barW / 2} y={top - 6} textAnchor="middle" fontSize="10"
|
<text x={x + barW / 2} y={top - 6} textAnchor="middle" fontSize="10"
|
||||||
fill={INK} fontWeight="500">
|
style={{ fill: INK }} fontWeight="500">
|
||||||
{isAnchor ? fmt(s.end, 0) : fmtSigned(s.delta, 0)}
|
{isAnchor ? fmt(s.end, 0) : fmtSigned(s.delta, 0)}
|
||||||
</text>
|
</text>
|
||||||
<text x={x + barW / 2} y={PAD.t + plotH + 16} textAnchor="middle" fontSize="10" fill={INK}>
|
<text x={x + barW / 2} y={PAD.t + plotH + 16} textAnchor="middle" fontSize="10" style={{ fill: INK }}>
|
||||||
{labelLines.map((ln, li) => (
|
{labelLines.map((ln, li) => (
|
||||||
<tspan key={li} x={x + barW / 2} dy={li === 0 ? 0 : 11}>{ln}</tspan>
|
<tspan key={li} x={x + barW / 2} dy={li === 0 ? 0 : 11}>{ln}</tspan>
|
||||||
))}
|
))}
|
||||||
@ -558,7 +605,7 @@ export default function BridgeView({
|
|||||||
untagged bucket -- its label already reads "N untagged
|
untagged bucket -- its label already reads "N untagged
|
||||||
adjustments", so a ×N here would just repeat the count. */}
|
adjustments", so a ×N here would just repeat the count. */}
|
||||||
{!isAnchor && s.tagged && s.entries > 1 && (
|
{!isAnchor && s.tagged && s.entries > 1 && (
|
||||||
<text x={x + barW / 2} y={subY} textAnchor="middle" fontSize="9" fill={INK_DIM}>
|
<text x={x + barW / 2} y={subY} textAnchor="middle" fontSize="9" style={{ fill: INK_DIM }}>
|
||||||
×{s.entries}
|
×{s.entries}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
--border-light: #f3f4f6;
|
--border-light: #f3f4f6;
|
||||||
--accent-bg: #eff6ff;
|
--accent-bg: #eff6ff;
|
||||||
--accent-text: #1d4ed8;
|
--accent-text: #1d4ed8;
|
||||||
|
--chart-ink-dim: #6b7280;
|
||||||
|
--chart-axis: #d1d5db;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark palette tuned to Perspective's "Pro Dark" theme:
|
/* Dark palette tuned to Perspective's "Pro Dark" theme:
|
||||||
@ -27,6 +29,8 @@
|
|||||||
--border-light: #3b3f46;
|
--border-light: #3b3f46;
|
||||||
--accent-bg: rgba(39, 113, 170, 0.32);
|
--accent-bg: rgba(39, 113, 170, 0.32);
|
||||||
--accent-text: #4778c2;
|
--accent-text: #4778c2;
|
||||||
|
--chart-ink-dim: #9ca3af;
|
||||||
|
--chart-axis: #61656e;
|
||||||
}
|
}
|
||||||
|
|
||||||
body { margin: 0; background-color: var(--bg-primary); color: var(--text-primary); }
|
body { margin: 0; background-color: var(--bg-primary); color: var(--text-primary); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user