@leavepulse/ui 0.29.4 → 0.29.5

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.
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { Motion } from "motion-v"
13
13
  import { computed, ref, shallowRef, watch, onMounted, onBeforeUnmount } from "vue"
14
+ import { easeTravel, prefersReducedMotion } from "../composables/easing"
14
15
  import { usePillTransition } from "../composables/usePillTransition"
15
16
  import { useReveal } from "../composables/useReveal"
16
17
  import {
@@ -147,6 +148,10 @@ onMounted(() => {
147
148
  onBeforeUnmount(() => {
148
149
  ro?.disconnect()
149
150
  ro = null
151
+ if (presenceFrame) cancelAnimationFrame(presenceFrame)
152
+ if (scaleFrame) cancelAnimationFrame(scaleFrame)
153
+ if (rightFrame) cancelAnimationFrame(rightFrame)
154
+ if (gutterFrame) cancelAnimationFrame(gutterFrame)
150
155
  })
151
156
 
152
157
  // ── data ───────────────────────────────────────
@@ -163,6 +168,74 @@ const toggleSeries = (name: string) => {
163
168
  const allSeries = computed(() => normalise(props.series))
164
169
  const visible = computed(() => allSeries.value.filter((s) => !hidden.value.has(s.name)))
165
170
 
171
+ /*
172
+ * How present each series is, 1 to 0, driven per frame rather than by CSS.
173
+ *
174
+ * A curve leaves by collapsing onto the baseline: every point travels to zero
175
+ * together and the shape flattens into the axis before it fades. Moving the
176
+ * whole layer down instead slides it past the axis as a picture of a chart —
177
+ * the marks keep their shape on the way out, which reads as the plot being
178
+ * dragged away rather than the readings going to nothing.
179
+ *
180
+ * It has to be JS: the collapse is a new `d` on every frame, and no browser
181
+ * interpolates a path's `d` between two arbitrary shapes.
182
+ */
183
+ /* One duration for the series leaving and the axis it was measured against, so
184
+ the two read as a single change rather than two. 260ms sits in the 250-400ms
185
+ band a legend toggle wants: long enough to follow, short enough to feel like
186
+ a control responding. */
187
+ const SETTLE_MS = 260
188
+
189
+ const presence = ref(new Map<string, number>())
190
+ let presenceFrame: number | undefined
191
+
192
+ /*
193
+ * A series with no entry yet is fully present, NOT "0 because it is hidden".
194
+ *
195
+ * Falling back to the hidden flag raced the animation it was meant to start:
196
+ * the computed that draws the curve and the watcher that animates it both run
197
+ * on the same tick, and whichever order they land in, the fallback had already
198
+ * reported the end state — so a series being hidden jumped to the floor while
199
+ * one being shown eased up from it.
200
+ */
201
+ const presenceOf = (name: string) => presence.value.get(name) ?? 1
202
+
203
+ watch(
204
+ // A string, not the Set: `hidden` is replaced wholesale on every toggle, and a
205
+ // watcher handed the object itself compares the new reference to the old one
206
+ // and sees a change it cannot describe — or, with a stale copy in the array,
207
+ // no change at all. The names that are currently off say it exactly.
208
+ () =>
209
+ allSeries.value.map((s) => `${s.name}:${hidden.value.has(s.name) ? 0 : 1}`).join(","),
210
+ () => {
211
+ const targets = new Map(allSeries.value.map((s) => [s.name, hidden.value.has(s.name) ? 0 : 1]))
212
+ if (!props.animate || prefersReducedMotion()) {
213
+ presence.value = targets
214
+ return
215
+ }
216
+
217
+ const from = new Map(allSeries.value.map((s) => [s.name, presenceOf(s.name)]))
218
+ if ([...targets].every(([name, to]) => from.get(name) === to)) return
219
+
220
+ const start = performance.now()
221
+ if (presenceFrame) cancelAnimationFrame(presenceFrame)
222
+
223
+ const step = (now: number) => {
224
+ const t = Math.min(1, (now - start) / SETTLE_MS)
225
+ // Same curve as the axis: the curve and its scale travel together.
226
+ const eased = easeTravel(t)
227
+ const next = new Map<string, number>()
228
+ for (const [name, to] of targets) {
229
+ const a = from.get(name) ?? to
230
+ next.set(name, a + (to - a) * eased)
231
+ }
232
+ presence.value = next
233
+ presenceFrame = t < 1 ? requestAnimationFrame(step) : undefined
234
+ }
235
+ presenceFrame = requestAnimationFrame(step)
236
+ },
237
+ )
238
+
166
239
  const count = computed(() => pointCount(allSeries.value))
167
240
  const labels = computed(() => resolveLabels(props.series, props.labels, count.value))
168
241
  const banded = computed(() => props.type === "bar")
@@ -194,14 +267,29 @@ const stackTotals = computed(() => {
194
267
  return { pos, neg }
195
268
  })
196
269
 
270
+ /*
271
+ * Which axes EXIST is decided by the series as declared; only their ranges
272
+ * follow what is visible.
273
+ *
274
+ * Hiding the one series on the right axis used to remove that axis outright:
275
+ * its labels went, the gutter they reserved collapsed, the plot widened into
276
+ * the space, and any remaining right-axis series jumped across to the left
277
+ * scale — a toggle of one curve rearranging the whole frame. Keeping the axis
278
+ * while it has a series assigned to it means hiding a curve changes the curve.
279
+ */
280
+ const declaredRight = computed(() => allSeries.value.filter((s) => s.axis === "right"))
281
+ const declaredLeft = computed(() => allSeries.value.filter((s) => s.axis === "left"))
282
+
197
283
  const onRight = computed(() => visible.value.filter((s) => s.axis === "right"))
198
284
  const onLeft = computed(() => {
199
285
  // A chart whose every series asked for the right axis still needs the left
200
286
  // one to carry the grid, so treat that as "no split at all".
201
287
  const left = visible.value.filter((s) => s.axis === "left")
202
- return left.length || !onRight.value.length ? left : visible.value
288
+ return left.length || !declaredRight.value.length ? left : visible.value
203
289
  })
204
- const hasRightAxis = computed(() => onRight.value.length > 0 && onLeft.value !== visible.value)
290
+ const hasRightAxis = computed(
291
+ () => declaredRight.value.length > 0 && declaredLeft.value.length > 0,
292
+ )
205
293
 
206
294
  const scaleFor = (members: typeof visible.value) => {
207
295
  const totals = stackTotals.value
@@ -211,22 +299,110 @@ const scaleFor = (members: typeof visible.value) => {
211
299
  return niceScale(raw.min, raw.max, props.yTicks)
212
300
  }
213
301
 
214
- const scale = computed(() => scaleFor(onLeft.value))
302
+ const targetScale = computed(() =>
303
+ scaleFor(onLeft.value.length ? onLeft.value : declaredLeft.value),
304
+ )
305
+
306
+ /*
307
+ * The axis animates by interpolating its own BOUNDS, not by transitioning the
308
+ * elements drawn from them.
309
+ *
310
+ * Everything on the plot is positioned from min/max — the curves, the gridlines
311
+ * and the tick labels — so moving the range moves all three as one gesture, and
312
+ * the numbers arrive with the shape that made them change. Transitioning the
313
+ * marks instead would need the axis to animate separately, and it cannot: `y1`
314
+ * and `y2` on an SVG <line> never became CSS properties, so a gridline jumps
315
+ * while the curve eases, which is the two-beat effect this avoids.
316
+ */
317
+ const scale = ref(targetScale.value)
318
+ let scaleFrame: number | undefined
319
+
320
+ watch(targetScale, (to, from) => {
321
+ if (!props.animate || prefersReducedMotion() || !from) {
322
+ scale.value = to
323
+ return
324
+ }
325
+ if (from.min === to.min && from.max === to.max && from.step === to.step) return
326
+
327
+ const start = performance.now()
328
+ if (scaleFrame) cancelAnimationFrame(scaleFrame)
329
+
330
+ const tick = (now: number) => {
331
+ const t = Math.min(1, (now - start) / SETTLE_MS)
332
+ /* easeTravel, not easeOut: an ease-out covers most of the range in the first
333
+ frames, which on a big rescale (800 down to 40) looks like a snap with a
334
+ slow tail rather than a scale moving. */
335
+ const eased = easeTravel(t)
336
+ scale.value =
337
+ t < 1
338
+ ? {
339
+ min: from.min + (to.min - from.min) * eased,
340
+ max: from.max + (to.max - from.max) * eased,
341
+ /* The step stays the target's: it decides how many ticks there are,
342
+ and a travelling one would add and drop labels mid-move. */
343
+ step: to.step,
344
+ }
345
+ : to
346
+ scaleFrame = t < 1 ? requestAnimationFrame(tick) : undefined
347
+ }
348
+ scaleFrame = requestAnimationFrame(tick)
349
+ })
215
350
 
216
351
  /*
217
352
  * The right axis reuses the LEFT axis's tick COUNT so both sets of labels land
218
353
  * on the same gridlines. Two independent tick runs would draw two interleaved
219
354
  * grids across one plot, which reads as noise rather than as two scales.
220
355
  */
221
- const rightScale = computed(() => {
356
+ const targetRightScale = computed(() => {
222
357
  if (!hasRightAxis.value) return null
223
- const raw = extent(onRight.value, zeroBased.value)
224
- const steps = Math.max(1, axisTicks(scale.value.min, scale.value.max, scale.value.step).length - 1)
358
+ /* With every right-hand series hidden there is nothing to measure, so the
359
+ axis holds the range it had — an axis that keeps its place while its curve
360
+ leaves, rather than collapsing to a degenerate 0..1 behind it. */
361
+ const members = onRight.value.length ? onRight.value : declaredRight.value
362
+ const raw = extent(members, zeroBased.value)
363
+ /* Steps come from the TARGET left scale. Reading the animated one rebuilt this
364
+ axis from a fractional range every frame, so a right-hand series jittered
365
+ against a scale being redrawn underneath it. */
366
+ const steps = Math.max(
367
+ 1,
368
+ axisTicks(targetScale.value.min, targetScale.value.max, targetScale.value.step).length - 1,
369
+ )
225
370
  const nice = niceScale(raw.min, raw.max, steps)
226
371
  // Stretch the top so the run divides into exactly `steps` intervals.
227
372
  return { min: nice.min, max: nice.min + nice.step * steps, step: nice.step }
228
373
  })
229
374
 
375
+ // The right axis travels on the same clock as the left, or a series measured
376
+ // against it arrives before the one beside it.
377
+ const rightScale = ref(targetRightScale.value)
378
+ let rightFrame: number | undefined
379
+
380
+ watch(targetRightScale, (to, from) => {
381
+ if (!to || !from || !props.animate || prefersReducedMotion()) {
382
+ rightScale.value = to
383
+ return
384
+ }
385
+ if (from.min === to.min && from.max === to.max && from.step === to.step) return
386
+
387
+ const start = performance.now()
388
+ if (rightFrame) cancelAnimationFrame(rightFrame)
389
+
390
+ const tick = (now: number) => {
391
+ const t = Math.min(1, (now - start) / SETTLE_MS)
392
+ const eased = easeTravel(t)
393
+ rightScale.value =
394
+ t < 1
395
+ ? {
396
+ min: from.min + (to.min - from.min) * eased,
397
+ max: from.max + (to.max - from.max) * eased,
398
+ step: to.step,
399
+ }
400
+ : to
401
+ rightFrame = t < 1 ? requestAnimationFrame(tick) : undefined
402
+ }
403
+ rightFrame = requestAnimationFrame(tick)
404
+ })
405
+
230
406
  const fmtWith = (v: number, unit?: string, f?: (n: number) => string): string =>
231
407
  `${f ? f(v) : formatTick(v)}${unit ?? ""}`
232
408
 
@@ -243,23 +419,76 @@ const scaleOf = (s: { axis: "left" | "right" }) =>
243
419
 
244
420
  // ── layout ─────────────────────────────────────
245
421
  // Side padding tracks the widest tick label so long numbers never clip.
422
+ /*
423
+ * Labels come from the TARGET scale, never the one mid-flight.
424
+ *
425
+ * The animated bounds are fractional for most of their travel, so formatting
426
+ * them printed "173.4" where "200" belongs — and since the gutter is measured
427
+ * from the widest label, the whole plot breathed in and out for the length of
428
+ * every transition. The numbers a tick can read are the ones it will settle on;
429
+ * only where each sits is in motion.
430
+ */
246
431
  const yLabels = computed(() =>
247
- axisTicks(scale.value.min, scale.value.max, scale.value.step).map(fmt),
432
+ axisTicks(targetScale.value.min, targetScale.value.max, targetScale.value.step).map(fmt),
248
433
  )
249
434
 
250
435
  const rightLabels = computed(() =>
251
- rightScale.value
252
- ? axisTicks(rightScale.value.min, rightScale.value.max, rightScale.value.step).map(fmtRight)
436
+ targetRightScale.value
437
+ ? axisTicks(
438
+ targetRightScale.value.min,
439
+ targetRightScale.value.max,
440
+ targetRightScale.value.step,
441
+ ).map(fmtRight)
253
442
  : [],
254
443
  )
255
444
 
256
445
  const gutter = (labels: string[]) => 12 + Math.max(...labels.map((l) => l.length), 1) * 7
257
446
 
447
+ /*
448
+ * The gutters ease to their new width instead of resizing on the frame the
449
+ * labels change. Going from "800" to "30" narrows the left margin by a third of
450
+ * an inch in one step, and because the plot is measured from it, every mark
451
+ * shifted sideways while it was still travelling vertically — a diagonal slide
452
+ * nothing in the data asked for.
453
+ */
454
+ const targetGutters = computed(() => ({
455
+ left: props.showYAxis ? gutter(yLabels.value) : 8,
456
+ right: props.showYAxis && rightLabels.value.length ? gutter(rightLabels.value) : 12,
457
+ }))
458
+
459
+ const gutters = ref(targetGutters.value)
460
+ let gutterFrame: number | undefined
461
+
462
+ watch(targetGutters, (to, from) => {
463
+ if (!props.animate || prefersReducedMotion() || !from) {
464
+ gutters.value = to
465
+ return
466
+ }
467
+ if (from.left === to.left && from.right === to.right) return
468
+
469
+ const start = performance.now()
470
+ if (gutterFrame) cancelAnimationFrame(gutterFrame)
471
+
472
+ const tick = (now: number) => {
473
+ const t = Math.min(1, (now - start) / SETTLE_MS)
474
+ const eased = easeTravel(t)
475
+ gutters.value =
476
+ t < 1
477
+ ? {
478
+ left: from.left + (to.left - from.left) * eased,
479
+ right: from.right + (to.right - from.right) * eased,
480
+ }
481
+ : to
482
+ gutterFrame = t < 1 ? requestAnimationFrame(tick) : undefined
483
+ }
484
+ gutterFrame = requestAnimationFrame(tick)
485
+ })
486
+
258
487
  const box = computed<Box>(() => ({
259
488
  width: width.value,
260
489
  height: props.height,
261
- left: props.showYAxis ? gutter(yLabels.value) : 8,
262
- right: props.showYAxis && rightLabels.value.length ? gutter(rightLabels.value) : 12,
490
+ left: gutters.value.left,
491
+ right: gutters.value.right,
263
492
  top: 10,
264
493
  bottom: props.showXAxis ? 26 : 8,
265
494
  }))
@@ -276,16 +505,44 @@ const geo = computed(() => ({
276
505
  const geoOf = (s: { axis: "left" | "right" }) => ({ ...geo.value, ...scaleOf(s) })
277
506
 
278
507
  const gridLines = computed(() => {
279
- const left = axisTicks(scale.value.min, scale.value.max, scale.value.step)
280
- const right = rightScale.value
281
- ? axisTicks(rightScale.value.min, rightScale.value.max, rightScale.value.step)
508
+ // Ticks are the target's — they are the figures the axis will read — while
509
+ // the position each one takes comes from the scale as it currently stands.
510
+ const left = axisTicks(targetScale.value.min, targetScale.value.max, targetScale.value.step)
511
+ const right = targetRightScale.value
512
+ ? axisTicks(
513
+ targetRightScale.value.min,
514
+ targetRightScale.value.max,
515
+ targetRightScale.value.step,
516
+ )
282
517
  : []
283
- return left.map((v, i) => ({
284
- v,
285
- y: yAt(v, scale.value.min, scale.value.max, box.value),
286
- label: fmt(v),
287
- rightLabel: right[i] != null ? fmtRight(right[i]) : "",
288
- }))
518
+ /*
519
+ * A tick sits at its FRACTION of the axis, not at its value on the scale
520
+ * currently in flight.
521
+ *
522
+ * Projecting the value instead put "800" where 800 falls on a range still
523
+ * reading 0..30 — roughly seventeen hundred pixels above a plot 240 tall.
524
+ * The labels shot off the top, came back as the range caught up, and the
525
+ * chart looked like it had thrown them. As a fraction, the top tick is the
526
+ * top of the axis throughout, whatever number it happens to carry.
527
+ */
528
+ const span = targetScale.value.max - targetScale.value.min || 1
529
+ const plot = plotHeight(box.value)
530
+ return left.map((v, i) => {
531
+ const fraction = (v - targetScale.value.min) / span
532
+ return {
533
+ v,
534
+ /*
535
+ * Keyed by POSITION, not by value. A tick keyed by its value is a
536
+ * different element every time the range moves, so hiding a series
537
+ * replaced the whole axis at once — the numbers jumped to their new
538
+ * places instead of moving there.
539
+ */
540
+ key: i,
541
+ y: box.value.top + plot - fraction * plot,
542
+ label: fmt(v),
543
+ rightLabel: right[i] != null ? fmtRight(right[i]) : "",
544
+ }
545
+ })
289
546
  })
290
547
 
291
548
  /** Thin out x labels so they never collide at narrow widths. */
@@ -312,17 +569,33 @@ const baseY = computed(() =>
312
569
  ),
313
570
  )
314
571
 
572
+ /*
573
+ * Hidden series are still drawn — off the bottom of the plot and transparent.
574
+ * Dropping them from the list instead makes the legend feel like a switch that
575
+ * deletes a reading: the curve is simply not there on the next frame, while the
576
+ * axis it was scaled against slides to a new range behind it. Kept in the list,
577
+ * it falls out of the plot while the remaining series keep their place, which is
578
+ * what "stop showing this one" should look like.
579
+ */
315
580
  const lines = computed(() =>
316
- visible.value.map((s) => {
581
+ allSeries.value.map((s) => {
317
582
  const g = geoOf(s)
583
+ const p = presenceOf(s.name)
584
+ // Values pulled toward the axis floor, so the curve flattens into it.
585
+ const base = Math.min(Math.max(0, g.min), g.max)
586
+ const values = p === 1 ? s.values : s.values.map((v) => (v == null ? null : base + (v - base) * p))
318
587
  return {
319
588
  ...s,
320
- d: linePath(s.values, g, props.smooth),
321
- fill: props.type === "area" ? areaPath(s.values, g, props.smooth) : "",
589
+ values,
590
+ d: linePath(values, g, props.smooth),
591
+ fill: props.type === "area" ? areaPath(values, g, props.smooth) : "",
322
592
  /* A dashed series is a reference level (a budget, a target), not a
323
593
  measurement — dotting it implies readings that aren't there. */
324
594
  points: props.showPoints && !s.dashed,
325
595
  scale: scaleOf(s),
596
+ /* Fades only at the end of the collapse: going transparent while it is
597
+ still visibly moving loses the travel that explains what happened. */
598
+ alpha: Math.min(1, p * 3),
326
599
  }
327
600
  }),
328
601
  )
@@ -679,7 +952,7 @@ const gradientId = (name: string, index: number) =>
679
952
 
680
953
  <!-- grid + y ticks -->
681
954
  <g v-if="showGrid || showYAxis">
682
- <template v-for="g in gridLines" :key="g.v">
955
+ <template v-for="g in gridLines" :key="g.key">
683
956
  <line
684
957
  v-if="showGrid"
685
958
  :x1="box.left"
@@ -688,7 +961,7 @@ const gradientId = (name: string, index: number) =>
688
961
  :y2="g.y"
689
962
  stroke="var(--color-line)"
690
963
  stroke-width="1"
691
- />
964
+ />
692
965
  <text
693
966
  v-if="showYAxis"
694
967
  :x="box.left - 8"
@@ -745,45 +1018,35 @@ const gradientId = (name: string, index: number) =>
745
1018
  :opacity="active < 0 || active === b.index ? 1 : 0.45"
746
1019
  />
747
1020
 
748
- <!-- area fills -->
749
- <path
750
- v-for="(s, i) in lines"
751
- v-show="type === 'area'"
752
- :key="`f-${s.name}`"
753
- :d="s.fill"
754
- :fill="`url(#${gradientId(s.name, i)})`"
755
- />
756
-
757
- <!-- lines -->
758
- <path
759
- v-for="s in lines"
760
- v-show="type !== 'bar'"
761
- :key="`l-${s.name}`"
762
- :d="s.d"
763
- fill="none"
764
- :stroke="s.color"
765
- stroke-width="2"
766
- stroke-linecap="round"
767
- stroke-linejoin="round"
768
- :stroke-dasharray="s.dashed ? '5 4' : undefined"
769
- />
770
-
771
- <!-- every-point dots -->
772
- <template v-if="type !== 'bar'">
773
- <template v-for="s in lines" :key="`p-${s.name}`">
774
- <template v-if="s.points">
775
- <circle
776
- v-for="(v, i) in s.values"
777
- v-show="v != null"
778
- :key="i"
779
- :cx="xAt(i, count, box, banded)"
780
- :cy="v == null ? 0 : yAt(v, s.scale.min, s.scale.max, box)"
781
- r="2.5"
782
- :fill="s.color"
783
- />
784
- </template>
1021
+ <!-- one group per series, so hiding one drops only that curve -->
1022
+ <g v-for="(s, i) in lines" :key="`s-${s.name}`" :style="{ opacity: s.alpha }">
1023
+ <path
1024
+ v-show="type === 'area'"
1025
+ :d="s.fill"
1026
+ :fill="`url(#${gradientId(s.name, i)})`"
1027
+ />
1028
+ <path
1029
+ v-show="type !== 'bar'"
1030
+ :d="s.d"
1031
+ fill="none"
1032
+ :stroke="s.color"
1033
+ stroke-width="2"
1034
+ stroke-linecap="round"
1035
+ stroke-linejoin="round"
1036
+ :stroke-dasharray="s.dashed ? '5 4' : undefined"
1037
+ />
1038
+ <template v-if="type !== 'bar' && s.points">
1039
+ <circle
1040
+ v-for="(v, j) in s.values"
1041
+ v-show="v != null"
1042
+ :key="j"
1043
+ :cx="xAt(j, count, box, banded)"
1044
+ :cy="v == null ? 0 : yAt(v, s.scale.min, s.scale.max, box)"
1045
+ r="2.5"
1046
+ :fill="s.color"
1047
+ />
785
1048
  </template>
786
- </template>
1049
+ </g>
787
1050
  </g>
788
1051
 
789
1052
  <!-- crosshair -->
@@ -863,3 +1126,14 @@ const gradientId = (name: string, index: number) =>
863
1126
  </LpContextMenu>
864
1127
  </div>
865
1128
  </template>
1129
+
1130
+
1131
+ <style scoped>
1132
+ /*
1133
+ * A series leaving, and the axis it was measured against following it.
1134
+ *
1135
+ * Both run on the same duration so the curve and its numbers settle together:
1136
+ * the axis arriving first would read as the scale having changed for its own
1137
+ * reasons, rather than because a series left.
1138
+ */
1139
+ </style>