@kolkrabbi/kol-component 0.320.1 → 0.322.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.322.0",
|
|
4
4
|
"description": "The core component library",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"embla-carousel-react": "^8.6.0",
|
|
27
27
|
"pdfjs-dist": "^6.3.289",
|
|
28
28
|
"react-syntax-highlighter": "^16.1.1",
|
|
29
|
+
"@kolkrabbi/kol-growth": "^0.4.0",
|
|
29
30
|
"@kolkrabbi/kol-search": "^0.3.0",
|
|
30
|
-
"@kolkrabbi/kol-growth": "^0.3.0",
|
|
31
31
|
"@kolkrabbi/kol-markdown": "^0.2.0"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
package/src/index.js
CHANGED
|
@@ -129,7 +129,8 @@ export { default as ContentItem } from './molecules/ContentItem.jsx'
|
|
|
129
129
|
export { default as ContentCollection } from './organisms/ContentCollection.jsx'
|
|
130
130
|
export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
|
|
131
131
|
export { MenuPopover } from './molecules/MenuPopover.jsx'
|
|
132
|
-
export { ModalProvider, useModal } from './molecules/Modal.jsx'
|
|
132
|
+
export { ModalProvider, useModal, Dialog } from './molecules/Modal.jsx'
|
|
133
|
+
export { default as Combobox } from './molecules/Combobox.jsx'
|
|
133
134
|
export { default as PaletteHarmonyWheel } from './molecules/PaletteHarmonyWheel.jsx'
|
|
134
135
|
export { default as PropertyInput } from './molecules/PropertyInput.jsx'
|
|
135
136
|
export { default as ShapeDropdown } from './molecules/ShapeDropdown.jsx'
|
|
@@ -225,7 +226,7 @@ export { RailHeader, RailTabs, RailHint, RailActions, RailFoot, RailNav } from '
|
|
|
225
226
|
export { LabeledControlSection as RailSection, SettingsRow as RailRow } from './organisms/SettingsPanel.jsx'
|
|
226
227
|
export { default as SectionNewsletter } from './organisms/SectionNewsletter.jsx'
|
|
227
228
|
export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
|
|
228
|
-
export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
|
|
229
|
+
export { default as ColumnBrowser, ResizeHandle } from './organisms/ColumnBrowser.jsx'
|
|
229
230
|
export { default as KindPreview } from './molecules/KindPreview.jsx'
|
|
230
231
|
/* MediaPlayer — the one player (2026-10-02): `variant` video · audio, `frame` for the Quick Look
|
|
231
232
|
* window. VideoSheet · AudioSheet · AudioPreview · AudioPlayer are its aliases, on the ledger. */
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import SearchInput from './SearchInput.jsx'
|
|
3
|
+
import { MenuDropdownItem } from './MenuItem.jsx'
|
|
4
|
+
import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Combobox — A searchable value field: type to filter, pick a row. (MetricDashboardUpstream, kol-website 2026-10-31 —
|
|
8
|
+
* metric's filter-value picker, plausible.io's.) Built from the parts, never a fork of them: the field is `SearchInput`
|
|
9
|
+
* (outline, sm) and the list is `Dropdown`'s own panel (`kol-dd-panel` · `kol-dd-div` · `kol-dd-list`, rows
|
|
10
|
+
* `MenuDropdownItem` at Dropdown's sm row), matched to the field's width and hung 1px under it.
|
|
11
|
+
*
|
|
12
|
+
* <Combobox value={q} onChange={setQ} options={[{ value: '/blog', label: '/blog' }]} placeholder="Select a value" />
|
|
13
|
+
*
|
|
14
|
+
* `value` is the field's text; picking a row hands its `value` to `onChange`. Rows match on label or value, capped at 50.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/* the row Dropdown sets at sm (Dropdown.jsx ROW.sm) */
|
|
18
|
+
const ROW_SM = 'kol-mono-12 px-2 h-[var(--kol-ctl-sm)]'
|
|
19
|
+
|
|
20
|
+
export default function Combobox({ value, onChange, options = [], placeholder, ariaLabel, className = '' }) {
|
|
21
|
+
const [open, setOpen] = useState(false)
|
|
22
|
+
const popover = usePopover({
|
|
23
|
+
open,
|
|
24
|
+
onOpenChange: setOpen,
|
|
25
|
+
placement: 'bottom-start',
|
|
26
|
+
offset: -1,
|
|
27
|
+
flip: false,
|
|
28
|
+
matchReferenceWidth: true,
|
|
29
|
+
click: false,
|
|
30
|
+
role: 'listbox',
|
|
31
|
+
})
|
|
32
|
+
const q = value.toLowerCase()
|
|
33
|
+
const shown = options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)).slice(0, 50)
|
|
34
|
+
return (
|
|
35
|
+
<div ref={popover.refs.setReference} className={`relative ${className}`}>
|
|
36
|
+
<SearchInput
|
|
37
|
+
className="w-full"
|
|
38
|
+
variant="outline"
|
|
39
|
+
size="sm"
|
|
40
|
+
value={value}
|
|
41
|
+
placeholder={placeholder}
|
|
42
|
+
aria-label={ariaLabel}
|
|
43
|
+
onFocus={() => setOpen(true)}
|
|
44
|
+
onChange={(e) => { onChange(e.target?.value ?? e); setOpen(true) }}
|
|
45
|
+
onClear={() => onChange('')}
|
|
46
|
+
/>
|
|
47
|
+
<PopoverPanel popover={popover} panel={false} focus={false} className="kol-dd-panel" style={{ '--kol-dd-max-rows': 8, '--kol-dd-row-h': 'var(--kol-ctl-sm)' }}>
|
|
48
|
+
<div className="kol-dd-div" />
|
|
49
|
+
<div className="kol-dd-list" role="listbox">
|
|
50
|
+
{shown.map((o) => (
|
|
51
|
+
<MenuDropdownItem key={o.value} hover={false} size="sm" rowClass={ROW_SM} onClick={() => { onChange(o.value); setOpen(false) }}>
|
|
52
|
+
{o.label}
|
|
53
|
+
</MenuDropdownItem>
|
|
54
|
+
))}
|
|
55
|
+
{!shown.length && <div className={`${ROW_SM} flex items-center text-meta`}>No matches</div>}
|
|
56
|
+
</div>
|
|
57
|
+
</PopoverPanel>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -159,8 +159,10 @@ export function MenuDropdownItem({ onClick, onPointerEnter, onPointerLeave, disa
|
|
|
159
159
|
)
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
/* `className` — the rule's colour and spacing, whole (MetricDashboardUpstream, kol-website 2026-10-31): on a filled
|
|
163
|
+
* panel the default `border-oq-08` vanishes. */
|
|
164
|
+
export function MenuDropdownDivider({ className = 'border-oq-08 my-1' }) {
|
|
165
|
+
return <div className={`border-t ${className}`} />
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
/**
|
|
@@ -184,11 +186,15 @@ export function MenuDropdownDivider() {
|
|
|
184
186
|
* lists. A scrolling panel clips its own flyouts, so give it to a panel with no
|
|
185
187
|
* flyouts inside.
|
|
186
188
|
*
|
|
189
|
+
* `rowClass` (flyout) — the nest row's type, padding, height and fill, whole, as `MenuDropdownItem rowClass`.
|
|
190
|
+
* `panelClassName` / `panelStyle` (flyout) — the panel's surface and its place beside the row; a `marginLeft` gap
|
|
191
|
+
* mirrors to `marginRight` when the panel flips (MetricDashboardUpstream, kol-website 2026-10-31).
|
|
192
|
+
*
|
|
187
193
|
* Either way, a leaf MenuDropdownItem inside still closes the whole menu via the
|
|
188
194
|
* standard data-menu-item bubble; the nest row itself only toggles.
|
|
189
195
|
*/
|
|
190
|
-
export function MenuDropdownNest({ prefix, iconLeft, label, disabled, flyout = false, scroll = false, children }) {
|
|
191
|
-
if (flyout) return <MenuFlyoutNest prefix={prefix} iconLeft={iconLeft} label={label} disabled={disabled} scroll={scroll}>{children}</MenuFlyoutNest>
|
|
196
|
+
export function MenuDropdownNest({ prefix, iconLeft, label, disabled, flyout = false, scroll = false, rowClass, panelClassName, panelStyle, children }) {
|
|
197
|
+
if (flyout) return <MenuFlyoutNest prefix={prefix} iconLeft={iconLeft} label={label} disabled={disabled} scroll={scroll} rowClass={rowClass} panelClassName={panelClassName} panelStyle={panelStyle}>{children}</MenuFlyoutNest>
|
|
192
198
|
return <MenuAccordionNest prefix={prefix} iconLeft={iconLeft} label={label} disabled={disabled}>{children}</MenuAccordionNest>
|
|
193
199
|
}
|
|
194
200
|
|
|
@@ -233,7 +239,7 @@ function MenuAccordionNest({ prefix, iconLeft, label, disabled, children }) {
|
|
|
233
239
|
)
|
|
234
240
|
}
|
|
235
241
|
|
|
236
|
-
function MenuFlyoutNest({ prefix, iconLeft, label, disabled, scroll, children }) {
|
|
242
|
+
function MenuFlyoutNest({ prefix, iconLeft, label, disabled, scroll, rowClass, panelClassName, panelStyle, children }) {
|
|
237
243
|
const [open, setOpen] = useState(false)
|
|
238
244
|
const [fit, setFit] = useState({ dy: 0, flip: false })
|
|
239
245
|
const row = useRef(null)
|
|
@@ -271,7 +277,7 @@ function MenuFlyoutNest({ prefix, iconLeft, label, disabled, scroll, children })
|
|
|
271
277
|
requestAnimationFrame(() => panel.current?.querySelector('button:not(:disabled)')?.focus())
|
|
272
278
|
}
|
|
273
279
|
}}
|
|
274
|
-
className={`${NEST_ROW} ${open ? 'text-emphasis' : 'text-body hover:text-emphasis'}`}
|
|
280
|
+
className={rowClass ? `kol-menu-btn w-full shrink-0 flex items-center gap-2 text-left disabled:opacity-40 disabled:cursor-not-allowed ${rowClass}` : `${NEST_ROW} ${open ? 'text-emphasis' : 'text-body hover:text-emphasis'}`}
|
|
275
281
|
>
|
|
276
282
|
<NestRowBody prefix={prefix} iconLeft={iconLeft} label={label} />
|
|
277
283
|
<Icon name="chevron-right" size={10} className="text-emphasis shrink-0" />
|
|
@@ -281,8 +287,8 @@ function MenuFlyoutNest({ prefix, iconLeft, label, disabled, scroll, children })
|
|
|
281
287
|
<div
|
|
282
288
|
ref={panel}
|
|
283
289
|
role="menu"
|
|
284
|
-
className={`absolute ${fit.flip ? 'right-full' : 'left-full'} flex flex-col w-max min-w-[180px] max-w-[320px] py-1 bg-surface-secondary rounded border border-oq-08 shadow-lg
|
|
285
|
-
style={{ top: -5 + fit.dy }}
|
|
290
|
+
className={`absolute ${fit.flip ? 'right-full' : 'left-full'} flex flex-col w-max min-w-[180px] max-w-[320px] z-10 ${panelClassName ?? 'py-1 bg-surface-secondary rounded border border-oq-08 shadow-lg'} ${scroll ? 'max-h-[60vh] overflow-y-auto' : ''}`}
|
|
291
|
+
style={{ top: -5 + fit.dy, ...(fit.flip && panelStyle?.marginLeft != null ? { ...panelStyle, marginLeft: undefined, marginRight: panelStyle.marginLeft } : panelStyle) }}
|
|
286
292
|
>
|
|
287
293
|
{children}
|
|
288
294
|
</div>
|
package/src/molecules/Modal.jsx
CHANGED
|
@@ -278,3 +278,42 @@ export function useModal() {
|
|
|
278
278
|
},
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Dialog — Modal's shell holding any content (MetricDashboardUpstream, kol-website 2026-10-31 — metric's filter and
|
|
284
|
+
* segment dialogs). The same `.kol-overlay-scrim` + `.kol-modal` surface as the prompts above, with `children` in place
|
|
285
|
+
* of a prompt. **z 150, not Modal's 1000**, so KOL's portalled floats (z 210+) open ABOVE it — a Dropdown or Combobox
|
|
286
|
+
* inside a dialog has to show its list (a native `<dialog>` cannot: `showModal()` puts it in the top layer, over every
|
|
287
|
+
* portal). Esc and a press on the scrim close it; Esc is left to an open float inside first.
|
|
288
|
+
*
|
|
289
|
+
* <Dialog open={open} onClose={() => setOpen(false)} title="Filter by page" width={560}>…</Dialog>
|
|
290
|
+
*/
|
|
291
|
+
export function Dialog({ open, onClose, title, width = 560, children }) {
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (!open) return undefined
|
|
294
|
+
const onKey = (e) => { if (e.key === 'Escape' && !document.querySelector('.kol-popover-float')) onClose() }
|
|
295
|
+
window.addEventListener('keydown', onKey)
|
|
296
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
297
|
+
}, [open, onClose])
|
|
298
|
+
if (!open || typeof document === 'undefined') return null
|
|
299
|
+
return createPortal(
|
|
300
|
+
<div
|
|
301
|
+
onMouseDown={onClose}
|
|
302
|
+
className="kol-overlay-scrim"
|
|
303
|
+
style={{ position: 'fixed', inset: 0, zIndex: 150, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
304
|
+
>
|
|
305
|
+
<div
|
|
306
|
+
role="dialog"
|
|
307
|
+
aria-modal="true"
|
|
308
|
+
aria-label={title}
|
|
309
|
+
data-dialog
|
|
310
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
311
|
+
className="kol-modal flex flex-col rounded border border-fg-08 max-h-[85vh]"
|
|
312
|
+
style={{ background: 'var(--kol-surface-primary)', color: 'var(--kol-surface-on-primary)', width: `min(${width}px, calc(100vw - 32px))` }}
|
|
313
|
+
>
|
|
314
|
+
{children}
|
|
315
|
+
</div>
|
|
316
|
+
</div>,
|
|
317
|
+
document.body,
|
|
318
|
+
)
|
|
319
|
+
}
|
|
@@ -477,7 +477,7 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPr
|
|
|
477
477
|
* sidenav. the handles are different from the sidenav"*) — a 2.8s chase and a
|
|
478
478
|
* 30px retarget, so the pill tracks the pointer instead of landing and holding
|
|
479
479
|
* on the rail's 90px dwell. */
|
|
480
|
-
function ResizeHandle({ axis, onDrag, onEnd }) {
|
|
480
|
+
export function ResizeHandle({ axis, onDrag, onEnd }) {
|
|
481
481
|
const [dragging, setDragging] = useState(false)
|
|
482
482
|
const start = useRef(null)
|
|
483
483
|
const ref = useRef(null)
|
|
@@ -4,104 +4,184 @@ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
|
4
4
|
|
|
5
5
|
/* THE GROWING LINE AS A STAGE (GrowthLineStage, kol-atlas 2026-10-30) — kol-fxr's HomeStage sim loop on
|
|
6
6
|
* @kolkrabbi/kol-growth: one closed line grows into `shape`, packing tighter where it is dark. One <canvas> filling its
|
|
7
|
-
* box at dpr ≤ 2; the sim runs in CSS px, so the spacing reads the same on a phone as on a desk.
|
|
8
|
-
*
|
|
9
|
-
* `data-theme` flip repaints a finished line; the stage clears to transparent, no paper.
|
|
7
|
+
* box at dpr ≤ 2; the sim runs in CSS px, so the spacing reads the same on a phone as on a desk. Ink is the canvas's
|
|
8
|
+
* computed `color` (a `text-*` class), a `data-theme` flip repaints a sleeping line; the stage clears to transparent.
|
|
10
9
|
*
|
|
11
|
-
* THE
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the window,
|
|
16
|
-
*
|
|
10
|
+
* THE LINE SETTLES (engine `settle`, GrowthLineIntro 2026-10-31, kol-fxr plan 45): every node carries a heat its motion,
|
|
11
|
+
* a split, the pointer or a pressing neighbour raise and that fades by itself; the loop steps while the line grows or
|
|
12
|
+
* anything is warm — two steps a frame while it grows, one while it settles — and goes still without a hard stop. It runs
|
|
13
|
+
* 48px past the edges (engine `bleed`), frozen there. THE POINTER IS A FORCE, soft: a move pushes the line aside as hard
|
|
14
|
+
* as it moves (a resting pointer is no force), a press blasts a crater it regrows into. Read on the window, so the stage
|
|
15
|
+
* can sit behind a page; a press on a control is the control's.
|
|
17
16
|
*
|
|
18
|
-
*
|
|
17
|
+
* A RESIZE KEEPS THE LINE — the nodes re-centre by half the size change, the shape is re-sampled at the new aspect, and
|
|
18
|
+
* a bigger box wakes the line to grow on into it; it never reseeds. A SLOW WAVE (`wave`, default 24 s · 9 px · 640 px
|
|
19
|
+
* crests) drifts the drawn line every frame, the sim untouched; `wave={false}` lets a settled line sleep (no rAF).
|
|
20
|
+
*
|
|
21
|
+
* THE INTRO (`intro`): one line from load to page. It grows on the shape's density (at `fit` 0.5) for 3.8 s, drawn only
|
|
22
|
+
* through the shape in the emphasis ink; rests 4 s breathing (ink 0.7–1.0 on a 2 s period, growth off after the first
|
|
23
|
+
* breath); then lets go over 4 s — the density blends shape → orb, `params` take over, every node warms, the stage's ink
|
|
24
|
+
* fades up as the shape's fades out, and the pointer waits until it ends. `onIntroEnd` fires then.
|
|
25
|
+
*
|
|
26
|
+
* REDUCED MOTION — no intro (`onIntroEnd` fires at once), no wave, no pointer force: the finished line is grown off
|
|
27
|
+
* screen and drawn once.
|
|
19
28
|
*
|
|
20
29
|
* `shape` — 'orb' · an SVG path `d` (centred at `fit` of the box) · `(w, h) => canvas` (dark on light). */
|
|
21
|
-
const ORB_PARAMS = { minGap: 6, maxGap: 24, contrast: 1.4, growth:
|
|
30
|
+
const ORB_PARAMS = { minGap: 6, maxGap: 24, contrast: 1.4, growth: 2.5 }
|
|
31
|
+
const WAVE = { ms: 24000, a: 9, l: 640 }
|
|
32
|
+
/* the intro, ms from its first frame: the line fills the shape · it breathes · the shape lets go */
|
|
33
|
+
const INTRO_PARAMS = { minGap: 5, maxGap: 18, contrast: 1.2, growth: 5 }
|
|
34
|
+
const MARK_MS = 3800, REST_MS = 4000, BREATH_MS = 2000, RELEASE_MS = 4000
|
|
22
35
|
|
|
23
|
-
const shapeCanvas = (shape, w, h, fit) => {
|
|
36
|
+
const shapeCanvas = (shape, w, h, fit, W) => {
|
|
24
37
|
if (typeof shape === 'function') return shape(w, h)
|
|
25
|
-
if (shape === 'orb' || !shape) return orbCanvas(w / h)
|
|
26
|
-
return pathCanvas(shape, w / h, fit)
|
|
38
|
+
if (shape === 'orb' || !shape) return orbCanvas(w / h, W)
|
|
39
|
+
return pathCanvas(shape, w / h, fit, W)
|
|
27
40
|
}
|
|
28
41
|
|
|
29
|
-
export default function GrowthLineStage({ shape = 'orb', fit
|
|
42
|
+
export default function GrowthLineStage({ shape = 'orb', fit, params = ORB_PARAMS, cap, pointer = true, seed, wave = WAVE, intro = false, onIntroEnd, className = '' }) {
|
|
30
43
|
const ref = useRef(null)
|
|
31
44
|
const reduced = usePrefersReducedMotion()
|
|
32
45
|
/* the props are read at (re)start; the effect re-runs when one changes by value */
|
|
33
|
-
const key = JSON.stringify([typeof shape === 'function' ? null : shape, fit, params, cap, pointer, seed, reduced])
|
|
46
|
+
const key = JSON.stringify([typeof shape === 'function' ? null : shape, fit, params, cap, pointer, seed, wave, intro, reduced])
|
|
34
47
|
const shapeRef = useRef(shape)
|
|
35
48
|
shapeRef.current = shape
|
|
49
|
+
const endRef = useRef(onIntroEnd)
|
|
50
|
+
endRef.current = onIntroEnd
|
|
36
51
|
|
|
37
52
|
useEffect(() => {
|
|
38
53
|
const canvas = ref.current
|
|
39
54
|
if (!canvas) return undefined
|
|
40
|
-
|
|
41
|
-
|
|
55
|
+
const k = fit ?? (intro ? 0.5 : 0.6)
|
|
56
|
+
const drift = wave && !reduced ? wave : null
|
|
57
|
+
const capOf = () => cap ?? Math.round(w * h / 90)
|
|
58
|
+
let sim = null, raf = 0, w = 0, h = 0, dpr = 1, it = null, orbed = false
|
|
59
|
+
let ptr = null, sm = null
|
|
42
60
|
const force = pointer && !reduced
|
|
43
|
-
const
|
|
61
|
+
const fitCanvas = () => {
|
|
44
62
|
const r = canvas.getBoundingClientRect()
|
|
45
|
-
w = Math.max(1, r.width); h = Math.max(1, r.height)
|
|
46
|
-
const src = shapeCanvas(shapeRef.current, w, h, fit)
|
|
47
|
-
if (!src) return false
|
|
48
|
-
const dpr = Math.min(2, window.devicePixelRatio || 1)
|
|
63
|
+
w = Math.max(1, r.width); h = Math.max(1, r.height); dpr = Math.min(2, window.devicePixelRatio || 1)
|
|
49
64
|
canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr)
|
|
50
65
|
canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
}
|
|
67
|
+
/* the shape as an alpha mask at the canvas's own resolution (dark → opaque), and the layer the line is drawn on
|
|
68
|
+
* before the mask cuts it */
|
|
69
|
+
const masks = () => {
|
|
70
|
+
const src = shapeCanvas(shapeRef.current, w, h, k, canvas.width)
|
|
71
|
+
const m = document.createElement('canvas'); m.width = canvas.width; m.height = canvas.height
|
|
72
|
+
const g = m.getContext('2d', { willReadFrequently: true })
|
|
73
|
+
if (src) g.drawImage(src, 0, 0, m.width, m.height)
|
|
74
|
+
const img = g.getImageData(0, 0, m.width, m.height), d = img.data
|
|
75
|
+
for (let i = 0; i < d.length; i += 4) { d[i + 3] = 255 - (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]); d[i] = d[i + 1] = d[i + 2] = 0 }
|
|
76
|
+
g.putImageData(img, 0, 0)
|
|
77
|
+
it.mask = m
|
|
78
|
+
it.layer = document.createElement('canvas'); it.layer.width = canvas.width; it.layer.height = canvas.height
|
|
79
|
+
}
|
|
80
|
+
const end = () => { it = null; endRef.current?.() }
|
|
81
|
+
const start = () => {
|
|
82
|
+
fitCanvas()
|
|
83
|
+
const src = shapeCanvas(shapeRef.current, w, h, k)
|
|
84
|
+
if (!src) return false
|
|
85
|
+
sim = new Growth({ w, h, density: densityFrom(src), rand: seed == null ? Math.random : mulberry32(seed), cap: capOf() })
|
|
86
|
+
sim.setParams({ bleed: 48, settle: 60, ...(intro && !reduced ? INTRO_PARAMS : params) })
|
|
87
|
+
if (intro && !reduced) {
|
|
88
|
+
sim.seed(0.5, 0.5)
|
|
89
|
+
const probe = document.createElement('span'); probe.className = 'text-emphasis'; canvas.parentElement.append(probe)
|
|
90
|
+
it = { t0: 0, ink: 1, out: 0, bright: getComputedStyle(probe).color, mark: sim.density, mix: null }
|
|
91
|
+
probe.remove(); masks()
|
|
92
|
+
} else {
|
|
93
|
+
sim.seed()
|
|
94
|
+
if (intro) endRef.current?.()
|
|
95
|
+
}
|
|
61
96
|
return true
|
|
62
97
|
}
|
|
63
|
-
|
|
64
|
-
|
|
98
|
+
/* the shape lets go: the page's spacing, every node warm, the density blending from the shape to the orb */
|
|
99
|
+
const release = () => {
|
|
100
|
+
orbed = true
|
|
101
|
+
const orb = densityFrom(orbCanvas(w / h))
|
|
102
|
+
it.orb = orb; it.mix = { cols: orb.cols, rows: orb.rows, data: new Float32Array(orb.data.length) }
|
|
103
|
+
sim.density = it.mix
|
|
104
|
+
sim.setParams(params)
|
|
105
|
+
sim.full = false; sim.heat.fill(1); sim.hot = sim.x.length
|
|
106
|
+
}
|
|
107
|
+
const blend = (u) => {
|
|
108
|
+
const a = it.mark.data, b = it.orb.data, m = it.mix.data
|
|
109
|
+
/* the mark was sampled at its own grid; read it by position if the orb's differs */
|
|
110
|
+
if (a.length === m.length) for (let i = 0; i < m.length; i++) m[i] = a[i] + (b[i] - a[i]) * u
|
|
111
|
+
else for (let i = 0; i < m.length; i++) m[i] = b[i] * u
|
|
112
|
+
}
|
|
113
|
+
const paint = (now) => {
|
|
114
|
+
const g = canvas.getContext('2d'), ink = getComputedStyle(canvas).color
|
|
115
|
+
const wv = drift && { a: drift.a, k: (2 * Math.PI) / drift.l, p: (2 * Math.PI * now) / drift.ms }
|
|
65
116
|
g.clearRect(0, 0, w, h)
|
|
66
|
-
sim.draw(g, w, h, { ink
|
|
117
|
+
if (!it) { sim.draw(g, w, h, { ink, width: 1, wave: wv }); return }
|
|
118
|
+
const L = it.layer.getContext('2d')
|
|
119
|
+
L.globalCompositeOperation = 'source-over'; L.setTransform(1, 0, 0, 1, 0, 0); L.clearRect(0, 0, it.layer.width, it.layer.height)
|
|
120
|
+
L.setTransform(dpr, 0, 0, dpr, 0, 0); sim.draw(L, w, h, { ink: it.bright, width: 1, wave: wv })
|
|
121
|
+
/* the whole line in the stage's ink once the shape lets go, fading up; the shape's in the emphasis ink, at the breath */
|
|
122
|
+
if (it.out > 0) { g.globalAlpha = it.out; sim.draw(g, w, h, { ink, width: 1, wave: wv }); g.globalAlpha = 1 }
|
|
123
|
+
L.setTransform(1, 0, 0, 1, 0, 0); L.globalCompositeOperation = 'destination-in'; L.drawImage(it.mask, 0, 0)
|
|
124
|
+
g.save(); g.setTransform(1, 0, 0, 1, 0, 0)
|
|
125
|
+
g.globalAlpha = it.ink * (1 - it.out); g.drawImage(it.layer, 0, 0)
|
|
126
|
+
g.restore()
|
|
67
127
|
}
|
|
68
|
-
/*
|
|
69
|
-
const frame = () => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
128
|
+
/* the line steps while it grows or anything is warm; with a wave it is drawn every frame, without one it sleeps */
|
|
129
|
+
const frame = (now) => {
|
|
130
|
+
let steps = !sim.full ? 2 : sim.hot > 0 ? 1 : 0
|
|
131
|
+
if (it) {
|
|
132
|
+
if (!it.t0) it.t0 = now
|
|
133
|
+
const t = now - it.t0
|
|
134
|
+
if (t < MARK_MS) steps = 4
|
|
135
|
+
else if (t < MARK_MS + REST_MS) {
|
|
136
|
+
/* it keeps filling the shape's far corners through the first breath, then holds */
|
|
137
|
+
if (!it.rest && t > MARK_MS + BREATH_MS) { it.rest = true; sim.setParams({ growth: 0 }) }
|
|
138
|
+
it.ink = 0.7 + 0.3 * Math.cos((2 * Math.PI * (t - MARK_MS)) / BREATH_MS)
|
|
139
|
+
} else {
|
|
140
|
+
if (!it.free) { it.free = true; it.ink = 1; release() }
|
|
141
|
+
const u = Math.min(1, (t - MARK_MS - REST_MS) / RELEASE_MS)
|
|
142
|
+
blend(u); it.out = u
|
|
143
|
+
if (u >= 1) { sim.density = it.orb; end() }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (ptr && !it) {
|
|
147
|
+
/* low-passed — the raw one twitches; 0.35 follows without lag */
|
|
148
|
+
const to = sm ? { x: sm.x + (ptr.x - sm.x) * 0.35, y: sm.y + (ptr.y - sm.y) * 0.35 } : { x: ptr.x, y: ptr.y }
|
|
149
|
+
/* the push is the pointer's own speed — a resting pointer is no force, a slow drift a light brush, a sweep a shove */
|
|
150
|
+
const speed = sm ? Math.hypot(to.x - sm.x, to.y - sm.y) : 0
|
|
151
|
+
if (ptr.down || speed > 0.3) { sim.poke(to.x, to.y, ptr.down, ptr.down ? 1 : Math.min(1, speed / 6)); steps = Math.max(steps, 1) }
|
|
79
152
|
sm = to
|
|
80
153
|
} else sm = null
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
paint()
|
|
85
|
-
raf = growing || idle > 0 ? requestAnimationFrame(frame) : 0
|
|
86
|
-
}
|
|
87
|
-
const wake = () => { if (!raf && sim) raf = requestAnimationFrame(frame) }
|
|
88
|
-
const restart = () => {
|
|
89
|
-
cancelAnimationFrame(raf); raf = 0
|
|
90
|
-
if (!start()) return
|
|
91
|
-
/* reduced motion grows 4× the splits a step: nothing is watched growing, so it reaches the cap sooner */
|
|
92
|
-
if (reduced) sim.setParams({ growth: sim.growth * 4 })
|
|
93
|
-
raf = requestAnimationFrame(reduced ? settle : frame)
|
|
154
|
+
for (let i = 0; i < steps; i++) sim.step()
|
|
155
|
+
paint(now)
|
|
156
|
+
raf = drift || it || ptr || !sim.full || sim.hot > 0 ? requestAnimationFrame(frame) : 0
|
|
94
157
|
}
|
|
158
|
+
const wake = () => { if (!raf && sim && !reduced) raf = requestAnimationFrame(frame) }
|
|
95
159
|
/* reduced motion: grown to the end off screen, ~12ms of steps a frame so the page never blocks, then drawn once */
|
|
96
|
-
const
|
|
160
|
+
const grow = () => {
|
|
97
161
|
const t = performance.now()
|
|
98
|
-
while (sim.
|
|
99
|
-
if (sim.
|
|
162
|
+
while ((!sim.full || sim.hot > 0) && performance.now() - t < 12) sim.step()
|
|
163
|
+
if (!sim.full || sim.hot > 0) { raf = requestAnimationFrame(grow); return }
|
|
100
164
|
raf = 0
|
|
101
|
-
paint()
|
|
165
|
+
paint(0)
|
|
166
|
+
}
|
|
167
|
+
/* A RESIZE KEEPS THE LINE: re-centred by half the size change, the frame and the shape re-sampled, never reseeded */
|
|
168
|
+
const resize = () => {
|
|
169
|
+
const r = canvas.getBoundingClientRect()
|
|
170
|
+
if (!sim || (Math.round(r.width) === Math.round(w) && Math.round(r.height) === Math.round(h))) return
|
|
171
|
+
const ow = w, oh = h
|
|
172
|
+
fitCanvas()
|
|
173
|
+
const dx = (w - ow) / 2, dy = (h - oh) / 2
|
|
174
|
+
for (let i = 0; i < sim.x.length; i++) { sim.x[i] += dx; sim.y[i] += dy }
|
|
175
|
+
sim.w = w; sim.h = h
|
|
176
|
+
sim.setParams({ cap: capOf() })
|
|
177
|
+
if (it) masks()
|
|
178
|
+
/* released from the intro, the line's shape is the orb */
|
|
179
|
+
else { const src = orbed ? orbCanvas(w / h) : shapeCanvas(shapeRef.current, w, h, k); if (src) sim.density = densityFrom(src) }
|
|
180
|
+
if (w > ow || h > oh) { sim.full = false; sim.heat.fill(0.5); sim.hot = sim.x.length }
|
|
181
|
+
if (reduced) { cancelAnimationFrame(raf); raf = requestAnimationFrame(grow) } else wake()
|
|
102
182
|
}
|
|
103
|
-
|
|
104
|
-
const ro = new ResizeObserver(
|
|
183
|
+
if (start()) raf = requestAnimationFrame(reduced ? grow : frame)
|
|
184
|
+
const ro = new ResizeObserver(resize)
|
|
105
185
|
ro.observe(canvas)
|
|
106
186
|
const at = (e) => {
|
|
107
187
|
const r = canvas.getBoundingClientRect()
|
|
@@ -118,8 +198,8 @@ export default function GrowthLineStage({ shape = 'orb', fit = 0.6, params = ORB
|
|
|
118
198
|
window.addEventListener('pointerup', onUp)
|
|
119
199
|
document.addEventListener('pointerleave', onLeave)
|
|
120
200
|
}
|
|
121
|
-
/* the ink follows a theme flip on a
|
|
122
|
-
const mo = new MutationObserver(() => { if (!raf && sim) paint() })
|
|
201
|
+
/* the ink follows a theme flip on a sleeping line too */
|
|
202
|
+
const mo = new MutationObserver(() => { if (!raf && sim) paint(performance.now()) })
|
|
123
203
|
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
|
124
204
|
return () => {
|
|
125
205
|
cancelAnimationFrame(raf); ro.disconnect(); mo.disconnect()
|