@boyernick/standard-ui-react 0.1.1-canary.9 → 0.2.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.
Files changed (82) hide show
  1. package/package.json +5 -3
  2. package/src/accordion.tsx +5 -2
  3. package/src/alert-dialog.tsx +1 -1
  4. package/src/attachment.tsx +5 -2
  5. package/src/autocomplete.tsx +4 -3
  6. package/src/block-editor.tsx +1747 -0
  7. package/src/brand.tsx +2 -2
  8. package/src/breadcrumb.tsx +14 -7
  9. package/src/button.tsx +22 -11
  10. package/src/calendar.tsx +6 -3
  11. package/src/carousel.tsx +220 -53
  12. package/src/checkbox.tsx +3 -2
  13. package/src/code-block.tsx +66 -5
  14. package/src/collapsible.tsx +4 -2
  15. package/src/combobox.tsx +4 -3
  16. package/src/command.tsx +27 -12
  17. package/src/dialog.tsx +33 -10
  18. package/src/empty.tsx +4 -4
  19. package/src/field.tsx +4 -3
  20. package/src/filter-group.tsx +143 -0
  21. package/src/icons.tsx +28 -1
  22. package/src/illustrations.tsx +219 -141
  23. package/src/index.ts +238 -18
  24. package/src/input.tsx +8 -5
  25. package/src/kbd.tsx +10 -4
  26. package/src/lib/focus.ts +41 -0
  27. package/src/lib/popup.ts +1 -1
  28. package/src/lifeline/company-icon.tsx +71 -0
  29. package/src/lifeline/index.ts +42 -0
  30. package/src/lifeline/lifeline-data.ts +97 -0
  31. package/src/lifeline/lifeline-desktop.tsx +204 -0
  32. package/src/lifeline/lifeline-event.tsx +108 -0
  33. package/src/lifeline/lifeline-fireworks.tsx +302 -0
  34. package/src/lifeline/lifeline-hover-image.tsx +269 -0
  35. package/src/lifeline/lifeline-icons.tsx +39 -0
  36. package/src/lifeline/lifeline-intro-timing.ts +105 -0
  37. package/src/lifeline/lifeline-labels.tsx +24 -0
  38. package/src/lifeline/lifeline-layout.ts +1 -0
  39. package/src/lifeline/lifeline-legend.tsx +31 -0
  40. package/src/lifeline/lifeline-lightbox.tsx +309 -0
  41. package/src/lifeline/lifeline-marker.tsx +183 -0
  42. package/src/lifeline/lifeline-people.tsx +99 -0
  43. package/src/lifeline/lifeline-photos.tsx +366 -0
  44. package/src/lifeline/lifeline-shell.tsx +135 -0
  45. package/src/lifeline/lifeline-utils.ts +83 -0
  46. package/src/lifeline/lifeline-vertical.tsx +482 -0
  47. package/src/lifeline/lifeline.tsx +69 -0
  48. package/src/lifeline/types.ts +112 -0
  49. package/src/lifeline/use-lifeline-intro.ts +130 -0
  50. package/src/lifeline/use-lifeline-scroll.ts +1011 -0
  51. package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
  52. package/src/menubar.tsx +9 -2
  53. package/src/minimap.tsx +296 -0
  54. package/src/modal.tsx +608 -0
  55. package/src/navigation-menu.tsx +338 -67
  56. package/src/number-field.tsx +336 -61
  57. package/src/otp-field.tsx +4 -3
  58. package/src/pagination.tsx +262 -42
  59. package/src/password-protection.tsx +345 -0
  60. package/src/popover.tsx +1 -1
  61. package/src/progress.tsx +5 -0
  62. package/src/questionnaire.tsx +284 -0
  63. package/src/radio.tsx +4 -2
  64. package/src/scroll-area.tsx +4 -1
  65. package/src/select.tsx +5 -2
  66. package/src/sidebar.tsx +113 -28
  67. package/src/slider.tsx +66 -6
  68. package/src/sounds.tsx +6 -10
  69. package/src/spinner.tsx +105 -32
  70. package/src/switch.tsx +4 -1
  71. package/src/table.tsx +82 -15
  72. package/src/tabs.tsx +189 -38
  73. package/src/text-animate.tsx +71 -28
  74. package/src/textarea.tsx +6 -1
  75. package/src/timeline.tsx +378 -0
  76. package/src/toast.tsx +214 -35
  77. package/src/toggle.tsx +4 -2
  78. package/src/toolbar.tsx +12 -7
  79. package/src/tooltip.tsx +43 -3
  80. package/src/video-player.tsx +396 -127
  81. package/src/image-modal.tsx +0 -110
  82. package/src/markdown-editor.tsx +0 -302
@@ -0,0 +1,366 @@
1
+ "use client"
2
+
3
+ import { useRef, useState, type CSSProperties, type PointerEvent } from "react"
4
+ import { cn } from "../lib/cn"
5
+ import { LifelineEventMedia } from "./lifeline-event"
6
+ import {
7
+ LifelineLightbox,
8
+ type LifelineLightboxStart,
9
+ } from "./lifeline-lightbox"
10
+ import type { LifelineMarker, LifelinePhoto } from "./types"
11
+ import { seededUnit } from "./lifeline-utils"
12
+
13
+ /** Tweak these */
14
+ const CARD_WIDTH = 180
15
+ /** Matches the events column's pt-6 — cards align with the first event's text. */
16
+ const EVENT_TOP = 24
17
+ const MAX_TILT_DEG = 6
18
+ /**
19
+ * How far along a card the next one in the stack starts — 0.6 leaves
20
+ * a solid margin of every card visible under its neighbor.
21
+ */
22
+ const STACK_OVERLAP = 0.6
23
+ /**
24
+ * Stacks cascade diagonally: the last card sits level with the event
25
+ * text and each one before it hangs this much lower, so neighbors
26
+ * overlap corner-to-corner instead of side-by-side.
27
+ */
28
+ const CASCADE_Y = 170
29
+ /** Event text column: max-w-[18rem] plus breathing room. */
30
+ const TEXT_ZONE = 288 + 24
31
+ /** Pointer travel below this is a click (opens the lightbox), not a drag. */
32
+ const CLICK_SLOP = 4
33
+ /** Fingers wobble more than mice — touch presses get extra tap room. */
34
+ const TOUCH_CLICK_SLOP = 10
35
+
36
+ function randomTilt(seed: string) {
37
+ return 2 + seededUnit(seed) * (MAX_TILT_DEG - 2)
38
+ }
39
+
40
+ /**
41
+ * The interactive photo card, positioning-agnostic: drag moves it for
42
+ * the session, a press without travel expands it into the lightbox.
43
+ * Desktop floats it over the track (absolute + left/top); the vertical
44
+ * layout drops it into normal flow.
45
+ */
46
+ export function LifelinePhotoCard({
47
+ photo,
48
+ rotate,
49
+ width,
50
+ className,
51
+ style,
52
+ animateIntro = false,
53
+ introDelay = 0,
54
+ introDuration = 420,
55
+ }: {
56
+ photo: LifelinePhoto
57
+ /** Resolved resting tilt, degrees. */
58
+ rotate: number
59
+ width: number
60
+ /** Positioning context from the caller (e.g. "absolute"). */
61
+ className?: string
62
+ style?: CSSProperties
63
+ animateIntro?: boolean
64
+ introDelay?: number
65
+ introDuration?: number
66
+ }) {
67
+ const [offset, setOffset] = useState({ x: 0, y: 0 })
68
+ const [active, setActive] = useState(false)
69
+ const [lightboxStart, setLightboxStart] =
70
+ useState<LifelineLightboxStart | null>(null)
71
+ const cardRef = useRef<HTMLDivElement>(null)
72
+ const drag = useRef({
73
+ startX: 0,
74
+ startY: 0,
75
+ baseX: 0,
76
+ baseY: 0,
77
+ moved: false,
78
+ slop: CLICK_SLOP,
79
+ })
80
+
81
+ const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
82
+ // The desktop track scrubs on drag — a card drag must not reach it.
83
+ event.stopPropagation()
84
+ event.preventDefault()
85
+ event.currentTarget.setPointerCapture(event.pointerId)
86
+ drag.current = {
87
+ startX: event.clientX,
88
+ startY: event.clientY,
89
+ baseX: offset.x,
90
+ baseY: offset.y,
91
+ moved: false,
92
+ slop: event.pointerType === "touch" ? TOUCH_CLICK_SLOP : CLICK_SLOP,
93
+ }
94
+ setActive(true)
95
+ }
96
+
97
+ const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
98
+ if (!active) return
99
+ const dx = event.clientX - drag.current.startX
100
+ const dy = event.clientY - drag.current.startY
101
+ if (Math.hypot(dx, dy) > drag.current.slop) drag.current.moved = true
102
+ setOffset({ x: drag.current.baseX + dx, y: drag.current.baseY + dy })
103
+ }
104
+
105
+ // The card's real geometry: bounding-box center (rotation preserves
106
+ // it) plus untransformed layout size — never the rotated hull, which
107
+ // is what made the lightbox clone jump on open.
108
+ const measureCard = (): LifelineLightboxStart | null => {
109
+ const el = cardRef.current
110
+ if (!el) return null
111
+ const rect = el.getBoundingClientRect()
112
+ // Recover the rendered scale (hover grows the card 3%) from the
113
+ // rotated hull: for tilt θ, hullWidth = (w·cosθ + h·sinθ)·scale.
114
+ const w0 = el.offsetWidth
115
+ const h0 = el.offsetHeight
116
+ const rad = Math.abs((rotate * Math.PI) / 180)
117
+ const hull = w0 * Math.cos(rad) + h0 * Math.sin(rad)
118
+ const scale = hull > 0 ? rect.width / hull : 1
119
+ return {
120
+ cx: rect.left + rect.width / 2,
121
+ cy: rect.top + rect.height / 2,
122
+ w: w0 * scale,
123
+ h: h0 * scale,
124
+ mediaTime: el.querySelector("video")?.currentTime,
125
+ }
126
+ }
127
+
128
+ const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
129
+ event.currentTarget.releasePointerCapture(event.pointerId)
130
+ setActive(false)
131
+ // A press that never travelled is a click — expand to the lightbox.
132
+ if (!drag.current.moved && !lightboxStart) {
133
+ setLightboxStart(measureCard())
134
+ }
135
+ }
136
+
137
+ // The browser claiming the gesture (a vertical pan-y scroll on
138
+ // touch) is not a click — reset without opening.
139
+ const onPointerCancel = (event: PointerEvent<HTMLDivElement>) => {
140
+ event.currentTarget.releasePointerCapture(event.pointerId)
141
+ setActive(false)
142
+ setOffset({ x: drag.current.baseX, y: drag.current.baseY })
143
+ }
144
+
145
+ return (
146
+ <>
147
+ <div
148
+ ref={cardRef}
149
+ data-lifeline-interactive=""
150
+ className={cn(
151
+ // pan-y keeps page scrolling alive on touch: a vertical swipe
152
+ // starting on a card scrolls the timeline (the browser claims
153
+ // the gesture and fires pointercancel); horizontal drags move
154
+ // the card.
155
+ "group/photo pointer-events-auto cursor-grab touch-pan-y",
156
+ active ? "z-50 cursor-grabbing" : "z-20 hover:z-40",
157
+ lightboxStart && "invisible",
158
+ className,
159
+ )}
160
+ style={
161
+ {
162
+ ...style,
163
+ width,
164
+ transform: `translate(${offset.x}px, ${offset.y}px) rotate(${rotate}deg)`,
165
+ } as CSSProperties
166
+ }
167
+ onPointerDown={onPointerDown}
168
+ onPointerMove={onPointerMove}
169
+ onPointerUp={onPointerUp}
170
+ onPointerCancel={onPointerCancel}
171
+ >
172
+ <div
173
+ className={cn(
174
+ "overflow-hidden rounded-xl shadow-xl ring-1 ring-border-primary transition-[transform,box-shadow] duration-200 ease-out",
175
+ animateIntro && "lifeline-marker-intro",
176
+ active
177
+ ? "scale-[1.05] shadow-2xl"
178
+ : "group-hover/photo:scale-[1.03] group-hover/photo:shadow-2xl",
179
+ )}
180
+ style={
181
+ animateIntro
182
+ ? ({
183
+ animationDelay: `${introDelay}ms`,
184
+ "--lifeline-marker-fade-ms": `${introDuration}ms`,
185
+ } as CSSProperties)
186
+ : undefined
187
+ }
188
+ >
189
+ <LifelineEventMedia
190
+ media={photo}
191
+ className="pointer-events-none block w-full"
192
+ />
193
+ </div>
194
+ </div>
195
+ {lightboxStart && (
196
+ <LifelineLightbox
197
+ photo={photo}
198
+ rotate={rotate}
199
+ start={lightboxStart}
200
+ getHome={measureCard}
201
+ onClosed={() => setLightboxStart(null)}
202
+ />
203
+ )}
204
+ </>
205
+ )
206
+ }
207
+
208
+ function FloatingCard({
209
+ photo,
210
+ stackIndex,
211
+ stackCount,
212
+ x,
213
+ defaultY,
214
+ width,
215
+ animateIntro,
216
+ introDelay,
217
+ introDuration,
218
+ }: {
219
+ photo: LifelinePhoto
220
+ stackIndex: number
221
+ stackCount: number
222
+ /** Resolved left position within the track. */
223
+ x: number
224
+ /** Cascade position when the photo has no explicit y. */
225
+ defaultY: number
226
+ width: number
227
+ animateIntro: boolean
228
+ introDelay: number
229
+ introDuration: number
230
+ }) {
231
+ const y = photo.y ?? defaultY
232
+ // Rolled once per mount: solo cards flip a coin for direction;
233
+ // neighbors in a stack lean away from each other so the pile reads
234
+ // as scattered.
235
+ const [mountTilt] = useState(() => {
236
+ const unit = seededUnit(`${photo.src}:${stackIndex}`)
237
+ const sign =
238
+ stackCount > 1
239
+ ? stackIndex % 2 === 0
240
+ ? -1
241
+ : 1
242
+ : unit > 0.5
243
+ ? 1
244
+ : -1
245
+ return sign * randomTilt(`${photo.src}:${stackIndex}:tilt`)
246
+ })
247
+
248
+ return (
249
+ <LifelinePhotoCard
250
+ photo={photo}
251
+ rotate={photo.rotate ?? mountTilt}
252
+ width={width}
253
+ className="absolute"
254
+ style={{ left: x, top: `calc(var(--lifeline-rail) + ${y}px)` }}
255
+ animateIntro={animateIntro}
256
+ introDelay={introDelay}
257
+ introDuration={introDuration}
258
+ />
259
+ )
260
+ }
261
+
262
+ /**
263
+ * Always-visible media scattered over the timeline — anchored to their
264
+ * marker's slot, tilted and overlapping like photos in a notebook.
265
+ * Rendered inside the transformed track, so they ride the scroll;
266
+ * dragging repositions a card for the session.
267
+ */
268
+ export function LifelineFloatingPhotos({
269
+ markers,
270
+ offsets,
271
+ widths,
272
+ animateIntro = false,
273
+ getIntroDelay,
274
+ getIntroDuration,
275
+ }: {
276
+ markers: LifelineMarker[]
277
+ offsets: number[]
278
+ widths: number[]
279
+ animateIntro?: boolean
280
+ getIntroDelay?: (markerIndex: number) => number
281
+ getIntroDuration?: (markerIndex: number) => number
282
+ }) {
283
+ const trackEnd =
284
+ offsets.length > 0
285
+ ? offsets[offsets.length - 1] + widths[widths.length - 1]
286
+ : 0
287
+
288
+ // Intro sync: a card fades in when the rail tip reaches it, i.e. on
289
+ // the schedule of the marker whose slot its center sits over — which
290
+ // is usually days past its anchor.
291
+ const markerIndexAt = (x: number) => {
292
+ for (let index = offsets.length - 1; index >= 0; index--) {
293
+ if (offsets[index] <= x) return index
294
+ }
295
+ return 0
296
+ }
297
+
298
+ return (
299
+ <div aria-hidden="true" className="pointer-events-none absolute inset-0">
300
+ {markers.map((marker, index) => {
301
+ if (!marker.photos?.length) return null
302
+
303
+ // The card's free run: after this day's own text column, up to
304
+ // the start of the next day that has text of its own.
305
+ const zoneStart =
306
+ offsets[index] + (marker.events.length > 0 ? TEXT_ZONE : 0)
307
+ const nextTextIndex = markers.findIndex(
308
+ (candidate, candidateIndex) =>
309
+ candidateIndex > index && candidate.events.length > 0,
310
+ )
311
+ const zoneEnd =
312
+ nextTextIndex === -1 ? trackEnd : offsets[nextTextIndex]
313
+
314
+ // Stacks center as a group so a lone card sits in the middle
315
+ // of the gap and a pile spreads evenly around it. Each card
316
+ // starts STACK_OVERLAP of the way along the one beneath it.
317
+ const steps: number[] = []
318
+ let fan = 0
319
+ for (const stacked of marker.photos) {
320
+ steps.push(fan)
321
+ fan += (stacked.width ?? CARD_WIDTH) * STACK_OVERLAP
322
+ }
323
+ const photoCount = marker.photos.length
324
+ const lastPhoto = marker.photos[photoCount - 1]
325
+ const groupWidth =
326
+ steps[steps.length - 1] + (lastPhoto.width ?? CARD_WIDTH)
327
+
328
+ return marker.photos.map((photo, photoIndex) => {
329
+ const width = photo.width ?? CARD_WIDTH
330
+ // Default home: centered in the text-free run between this
331
+ // day's events and the next day that has text — comfortably
332
+ // away from both columns.
333
+ const x =
334
+ photo.x !== undefined
335
+ ? offsets[index] + photo.x * widths[index]
336
+ : Math.max(
337
+ zoneStart,
338
+ zoneStart +
339
+ (zoneEnd - zoneStart - groupWidth) / 2 +
340
+ steps[photoIndex],
341
+ )
342
+ const introIndex = markerIndexAt(x + width / 2)
343
+ // The last card of a stack sits level with the event text;
344
+ // earlier cards hang progressively lower — the diagonal.
345
+ const defaultY =
346
+ EVENT_TOP + (photoCount - 1 - photoIndex) * CASCADE_Y
347
+
348
+ return (
349
+ <FloatingCard
350
+ key={`${marker.id}-${photoIndex}`}
351
+ photo={photo}
352
+ stackIndex={photoIndex}
353
+ stackCount={photoCount}
354
+ x={x}
355
+ defaultY={defaultY}
356
+ width={width}
357
+ animateIntro={animateIntro}
358
+ introDelay={getIntroDelay?.(introIndex) ?? 0}
359
+ introDuration={getIntroDuration?.(introIndex) ?? 420}
360
+ />
361
+ )
362
+ })
363
+ })}
364
+ </div>
365
+ )
366
+ }
@@ -0,0 +1,135 @@
1
+ import type { ReactNode } from "react"
2
+ import { cn } from "../lib/cn"
3
+
4
+ /**
5
+ * The page framing the Lifeline expects.
6
+ *
7
+ * The rail is not sized by CSS — on desktop it measures where to start
8
+ * and end from the nav: `data-site-nav-logo` gives it the start, and the
9
+ * right edge of `data-site-nav-inner` gives it the end. That is what
10
+ * keeps the timeline inset from the viewport and aligned with the rest
11
+ * of the page, and it's the span the intro animation draws across.
12
+ *
13
+ * Change `containerClassName` on both the nav and the footer together
14
+ * and the rail follows. Drop the nav entirely and the rail falls back
15
+ * to filling its own container, edge to edge.
16
+ */
17
+
18
+ const CONTAINER = "mx-auto flex w-full max-w-5xl items-center px-6"
19
+
20
+ export function LifelineShell({
21
+ children,
22
+ className,
23
+ }: {
24
+ children: ReactNode
25
+ className?: string
26
+ }) {
27
+ return (
28
+ <div
29
+ className={cn(
30
+ "flex h-dvh flex-col overflow-hidden bg-background-primary text-fg-primary antialiased transition-colors duration-300",
31
+ className,
32
+ )}
33
+ >
34
+ {children}
35
+ </div>
36
+ )
37
+ }
38
+
39
+ export function LifelineNav({
40
+ logo,
41
+ logoHref = "/",
42
+ logoLabel = "Home",
43
+ children,
44
+ className,
45
+ containerClassName,
46
+ }: {
47
+ /** Rendered inside the marked anchor — the rail starts at its left edge. */
48
+ logo: ReactNode
49
+ logoHref?: string
50
+ /** Accessible name for the logo link. */
51
+ logoLabel?: string
52
+ /** Anything on the right: links, a theme switcher. */
53
+ children?: ReactNode
54
+ className?: string
55
+ containerClassName?: string
56
+ }) {
57
+ return (
58
+ <nav
59
+ className={cn(
60
+ "fixed inset-x-0 top-0 z-50 border-b border-border-primary bg-background-primary/80 backdrop-blur-xl transition-colors duration-300",
61
+ className,
62
+ )}
63
+ >
64
+ <div
65
+ data-site-nav-inner
66
+ className={cn(CONTAINER, "h-16 justify-between", containerClassName)}
67
+ >
68
+ <a
69
+ href={logoHref}
70
+ data-site-nav-logo
71
+ aria-label={logoLabel}
72
+ className="cursor-pointer text-fg-primary transition-[color,opacity] duration-300 hover:opacity-70"
73
+ >
74
+ {logo}
75
+ </a>
76
+
77
+ {children ? (
78
+ <div className="flex items-center gap-8">{children}</div>
79
+ ) : null}
80
+ </div>
81
+ </nav>
82
+ )
83
+ }
84
+
85
+ /**
86
+ * The stage. `pt-16` clears the fixed nav; `md:overflow-hidden` hands
87
+ * scrolling to the horizontal scrub above the mobile breakpoint.
88
+ */
89
+ export function LifelineStage({
90
+ children,
91
+ className,
92
+ }: {
93
+ children: ReactNode
94
+ className?: string
95
+ }) {
96
+ return (
97
+ <main
98
+ className={cn(
99
+ "flex-1 min-h-0 overflow-y-auto pt-16 md:overflow-hidden",
100
+ className,
101
+ )}
102
+ >
103
+ {children}
104
+ </main>
105
+ )
106
+ }
107
+
108
+ export function LifelineFooter({
109
+ children,
110
+ className,
111
+ containerClassName,
112
+ }: {
113
+ children?: ReactNode
114
+ className?: string
115
+ containerClassName?: string
116
+ }) {
117
+ return (
118
+ <footer
119
+ className={cn(
120
+ "shrink-0 border-t border-border-primary bg-background-primary/95 backdrop-blur-sm transition-colors duration-300",
121
+ className,
122
+ )}
123
+ >
124
+ <div
125
+ className={cn(
126
+ CONTAINER,
127
+ "h-16 justify-between gap-6",
128
+ containerClassName,
129
+ )}
130
+ >
131
+ {children}
132
+ </div>
133
+ </footer>
134
+ )
135
+ }
@@ -0,0 +1,83 @@
1
+ import type { LifelineMarker } from "./types"
2
+
3
+ /** Stable 0..1 noise so photo tilts match across SSR and the client. */
4
+ export function seededUnit(seed: string) {
5
+ let hash = 2166136261
6
+ for (let index = 0; index < seed.length; index++) {
7
+ hash ^= seed.charCodeAt(index)
8
+ hash = Math.imul(hash, 16777619)
9
+ }
10
+ return (hash >>> 0) / 4294967296
11
+ }
12
+
13
+ export function clamp(value: number, min: number, max: number) {
14
+ return Math.min(max, Math.max(min, value))
15
+ }
16
+
17
+ /**
18
+ * A composited layer resting on a fractional offset resamples its whole
19
+ * subtree — text goes soft. Snapping to the device pixel grid (not whole
20
+ * CSS pixels) keeps half-pixel steps on retina, so motion stays smooth.
21
+ */
22
+ export function snapToDevicePixel(value: number) {
23
+ const dpr = window.devicePixelRatio || 1
24
+ return Math.round(value * dpr) / dpr
25
+ }
26
+
27
+ export function hasMarkerContent(marker: LifelineMarker) {
28
+ return (
29
+ marker.events.length > 0 ||
30
+ (marker.companies?.length ?? 0) > 0 ||
31
+ (marker.mentors?.length ?? 0) > 0 ||
32
+ (marker.met?.length ?? 0) > 0
33
+ )
34
+ }
35
+
36
+ export function hasMarkerPeople(marker: LifelineMarker) {
37
+ return (marker.mentors?.length ?? 0) > 0 || (marker.met?.length ?? 0) > 0
38
+ }
39
+
40
+ export function getMarkerHeight(marker: LifelineMarker, nextYear?: number) {
41
+ const hasContent = hasMarkerContent(marker)
42
+ const hasPeople = hasMarkerPeople(marker)
43
+
44
+ if (!hasContent) return 48
45
+
46
+ const peopleOnly =
47
+ hasPeople &&
48
+ marker.events.length === 0 &&
49
+ (marker.companies?.length ?? 0) === 0
50
+
51
+ let height = 96
52
+
53
+ if (marker.companies?.length) height += 28
54
+ height += marker.events.length * 44
55
+
56
+ if (peopleOnly) height += 88
57
+ else if (hasPeople) height += 108
58
+
59
+ if (!nextYear) return Math.min(520, Math.max(peopleOnly ? 148 : 188, height))
60
+
61
+ const gap = Math.max(1, nextYear - marker.year)
62
+ height += Math.min(32, gap * 3)
63
+
64
+ return Math.min(520, Math.max(peopleOnly ? 148 : 188, height))
65
+ }
66
+
67
+ export function getMarkerWidth(marker: LifelineMarker, nextYear?: number) {
68
+ const hasContent = hasMarkerContent(marker)
69
+ const hasPeople = hasMarkerPeople(marker)
70
+
71
+ if (!nextYear) return hasContent ? 360 : 80
72
+ if (!hasContent) return 80
73
+
74
+ const peopleOnly =
75
+ hasPeople &&
76
+ marker.events.length === 0 &&
77
+ (marker.companies?.length ?? 0) === 0
78
+
79
+ if (peopleOnly) return 220
80
+
81
+ const gap = Math.max(1, nextYear - marker.year)
82
+ return Math.min(420, Math.max(290, gap * 36))
83
+ }