@djangocfg/ui-tools 2.1.431 → 2.1.433

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 (171) hide show
  1. package/package.json +28 -14
  2. package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
  3. package/src/common/CodePanelHeader/index.ts +1 -0
  4. package/src/common/blocks/BlockError.tsx +46 -0
  5. package/src/common/blocks/index.ts +34 -0
  6. package/src/common/blocks/schemas.ts +143 -0
  7. package/src/common/blocks/types.ts +76 -0
  8. package/src/common/chips/README.md +67 -0
  9. package/src/common/chips/UrlChip.tsx +105 -0
  10. package/src/common/chips/index.ts +20 -0
  11. package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
  12. package/src/common/link-preview/index.ts +12 -0
  13. package/src/common/link-preview/types.ts +44 -0
  14. package/src/common/tiptap/index.ts +10 -0
  15. package/src/common/tiptap/useNodeAttrs.ts +128 -0
  16. package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
  17. package/src/tools/chat/README.md +88 -2
  18. package/src/tools/chat/composer/Composer.tsx +45 -12
  19. package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
  20. package/src/tools/chat/constants.ts +9 -2
  21. package/src/tools/chat/core/extractFirstUrl.ts +45 -0
  22. package/src/tools/chat/core/reducer.ts +32 -8
  23. package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
  24. package/src/tools/chat/core/transport/mock.ts +4 -1
  25. package/src/tools/chat/hooks/useChat.ts +20 -20
  26. package/src/tools/chat/lazy.tsx +10 -0
  27. package/src/tools/chat/messages/Attachments.tsx +267 -42
  28. package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
  29. package/src/tools/chat/messages/MessageBubble.tsx +175 -31
  30. package/src/tools/chat/messages/MessageList.tsx +107 -21
  31. package/src/tools/chat/messages/ToolCalls.tsx +28 -13
  32. package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
  33. package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
  34. package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
  35. package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
  36. package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
  37. package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
  38. package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
  39. package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
  40. package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
  41. package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
  42. package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
  43. package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
  44. package/src/tools/chat/messages/index.ts +13 -0
  45. package/src/tools/chat/public.ts +5 -1
  46. package/src/tools/chat/shell/ChatRoot.tsx +29 -0
  47. package/src/tools/chat/styles/bubbleTokens.ts +18 -0
  48. package/src/tools/chat/styles/index.ts +1 -0
  49. package/src/tools/chat/types/attachment.ts +26 -2
  50. package/src/tools/chat/types/block.ts +56 -9
  51. package/src/tools/chat/types/blockSchemas.ts +197 -0
  52. package/src/tools/chat/types/config.ts +33 -0
  53. package/src/tools/chat/types/events.ts +14 -7
  54. package/src/tools/chat/types/index.ts +4 -2
  55. package/src/tools/chat/types/message.ts +7 -0
  56. package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
  57. package/src/tools/dev/Map/README.md +30 -345
  58. package/src/tools/dev/Map/cards/index.ts +6 -0
  59. package/src/tools/dev/Map/cards/types.ts +67 -0
  60. package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
  61. package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
  62. package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
  63. package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
  64. package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
  65. package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
  66. package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
  67. package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
  68. package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
  69. package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
  70. package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
  71. package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
  72. package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
  73. package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
  74. package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
  75. package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
  76. package/src/tools/dev/Map/components/MapControls.tsx +27 -0
  77. package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
  78. package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
  79. package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
  80. package/src/tools/dev/Map/components/index.ts +35 -1
  81. package/src/tools/dev/Map/geocode/index.ts +2 -0
  82. package/src/tools/dev/Map/geocode/photon.ts +265 -0
  83. package/src/tools/dev/Map/hooks/index.ts +37 -0
  84. package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
  85. package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
  86. package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
  87. package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
  88. package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
  89. package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
  90. package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
  91. package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
  92. package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
  93. package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
  94. package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
  95. package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
  96. package/src/tools/dev/Map/index.ts +59 -2
  97. package/src/tools/dev/Map/layers/index.ts +1 -0
  98. package/src/tools/dev/Map/layers/symbol.ts +93 -0
  99. package/src/tools/dev/Map/lazy.tsx +120 -0
  100. package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
  101. package/src/tools/dev/Map/styles/index.ts +62 -0
  102. package/src/tools/dev/Map/styles/terrain.ts +29 -0
  103. package/src/tools/dev/Map/types.ts +31 -3
  104. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
  105. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
  106. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
  107. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
  108. package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
  109. package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
  110. package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
  111. package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
  112. package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
  113. package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
  114. package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
  115. package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
  116. package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
  117. package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
  118. package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
  119. package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
  120. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
  121. package/src/tools/forms/MarkdownEditor/README.md +84 -0
  122. package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
  123. package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
  124. package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
  125. package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
  126. package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
  127. package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
  128. package/src/tools/forms/MarkdownEditor/index.ts +19 -0
  129. package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
  130. package/src/tools/forms/MarkdownEditor/styles.css +134 -2
  131. package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
  132. package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
  133. package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
  134. package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
  135. package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
  136. package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
  137. package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
  138. package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
  139. package/src/tools/forms/NotionEditor/README.md +109 -188
  140. package/src/tools/forms/NotionEditor/extensions.ts +15 -0
  141. package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
  142. package/src/tools/forms/NotionEditor/styles.css +25 -0
  143. package/src/tools/forms/NotionEditor/types.ts +9 -0
  144. package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
  145. package/src/tools/input/SpeechRecognition/types.ts +10 -0
  146. package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
  147. package/src/tools/media/ImageViewer/README.md +9 -0
  148. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
  149. package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
  150. package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
  151. package/src/tools/media/ImageViewer/components/index.ts +2 -0
  152. package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
  153. package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
  154. package/src/tools/media/ImageViewer/types.ts +10 -0
  155. package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
  156. package/src/tools/media/ImageViewer/utils/index.ts +4 -0
  157. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
  158. package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
  159. package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
  160. package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
  161. package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
  162. package/src/tools/visual/charts/SmoothLine/README.md +97 -0
  163. package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
  164. package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
  165. package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
  166. package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
  167. package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
  168. package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
  169. package/src/tools/visual/indicators/Fps/types.ts +1 -1
  170. package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
  171. package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
@@ -0,0 +1,97 @@
1
+ # SmoothLine
2
+
3
+ Animated sibling of [`Sparkline`](../Sparkline/README.md). Same pure-SVG family
4
+ (fixed viewBox, `preserveAspectRatio="none"`, `vectorEffect="non-scaling-stroke"`),
5
+ but with **two** kinds of smoothness:
6
+
7
+ 1. **Curve smoothness (the line SHAPE).** The line is drawn as a flowing
8
+ **Catmull-Rom → cubic-bezier curve** through the points (`M … C …`), not a
9
+ jagged `M … L …` polyline — so it flows in gentle curves instead of kinking
10
+ at each data point. Controlled by `tension` (default `0.5`; `0` degenerates
11
+ to straight `L` segments — the disable path for charts that want SHARP
12
+ geometry like an ECG QRS spike). This is pure geometry: the curve is rebuilt
13
+ from the points each frame, so it both flows AND glides.
14
+ 2. **Streaming smoothness (the MOTION).** With `scroll` on, the trace slides
15
+ left at **constant velocity** like a hospital monitor (the professional
16
+ D3/Smoothie model) instead of snapping the `d` each tick. We animate exactly
17
+ ONE thing — a `translateX` on the scroll group — and the position is a pure
18
+ function of the **wall clock**, so it is frame-rate independent and never
19
+ eased. The path `d` is rebuilt **once per data change** from the true values
20
+ (no per-frame Y-tween), and the newest point is appended **off-screen** behind
21
+ a one-interval `delay` buffer + `clipPath`, so the visible window only ever
22
+ scrolls into view — it never changes shape instantly.
23
+
24
+ **The velocity is driven by the MEASURED data cadence, not a hardcoded
25
+ `tweenMs`.** The engine records each datum's arrival time and EMAs the deltas
26
+ into a smoothed interval; the slide velocity is `slotWidth / measuredInterval`.
27
+ That is what makes a **jittery 1 Hz feed** (the Go stats ticker + Wails event
28
+ bus add ±100-200 ms) still glide at a steady speed: a late sample lets the
29
+ phase glide *past* one slot into the buffer (no freeze-then-snap), and the
30
+ leftover phase is carried across the commit seam (no per-tick teleport).
31
+ `tweenMs` is now only the **seed/clamp** for the very first slides before any
32
+ delta is measured — the engine adapts to the true rate within a couple ticks.
33
+
34
+ ```tsx
35
+ import { SmoothLine } from '@djangocfg/ui-tools/smooth-line';
36
+
37
+ // stats charts tick ≈ 1 Hz
38
+ <SmoothLine values={rtt} color="info" tweenMs={1000} area />
39
+
40
+ // token bridge — inherit a text-* className tint (cmdop's text-chart-3)
41
+ <span className="text-chart-3">
42
+ <SmoothLine values={rtt} color="currentColor" />
43
+ </span>
44
+ ```
45
+
46
+ ## Props
47
+
48
+ | Prop | Type | Default | Description |
49
+ |---|---|---|---|
50
+ | `values` | `readonly number[]` | — | Series, oldest-first. Empty → flat baseline. |
51
+ | `color` | `SmoothLineColor` | `'primary'` | Semantic token, or `'currentColor'` to inherit a `text-*` tint. |
52
+ | `width` | `number` | `240` | viewBox width (SVG stretches to its container). |
53
+ | `height` | `number` | `40` | viewBox height (px). |
54
+ | `strokeWidth` | `number` | `1.5` | Line stroke. |
55
+ | `area` | `boolean` | `false` | Alpha area fill under the line. |
56
+ | `tweenMs` | `number` | `1000` | **Seed** cadence (ms) for the scroll velocity + the clamp band — NOT a fixed slide speed. The engine measures the real inter-sample interval and drives the velocity off that. Pass the rough expected cadence (stats ≈ 1000, quality ≈ 30000). |
57
+ | `scroll` | `boolean` | `false` | Continuous constant-velocity left-scroll for live windowed charts. |
58
+ | `delay` | `boolean` | `true` | Hold the right edge back one interval (buffer the newest point off-screen) so it scrolls in cleanly instead of "grow then snap". Requires `scroll`. |
59
+ | `tension` | `number` | `0.5` | Curve smoothness (the line SHAPE). `0` → straight `L` segments; `0.5` → flowing curve; `1` → loosest. |
60
+ | `ariaLabel` | `string` | — | Accessible label. |
61
+
62
+ ### Curve smoothing — for host-bespoke charts
63
+
64
+ The Catmull-Rom → bezier converter is exported so host charts that build their
65
+ own geometry (cmdop's TrafficChart mirror areas) can get the same flowing curve:
66
+
67
+ ```ts
68
+ import { smoothLinePath, smoothSegments } from '@djangocfg/ui-tools/smooth-line';
69
+
70
+ // full path through points (line stroke)
71
+ const d = smoothLinePath(points, 0.5, { width, height });
72
+ // just the C-segments (so an area fill can reuse the exact same curve)
73
+ const body = smoothSegments(points, 0.5, { width, height });
74
+ ```
75
+
76
+ The **ECG keeps its bespoke SHARP builder** (sharp QRS pulses) and does NOT use
77
+ this — a cardiogram needs sharp pulses, not rounded blobs. It reuses only the
78
+ glide+scroll engine (`useSmoothSeries`), not the curve smoothing.
79
+
80
+ ## `useSmoothSeries`
81
+
82
+ The rAF engine is exported as a hook so host-bespoke charts (e.g. cmdop's ECG,
83
+ which keeps its own QRS geometry) can reuse the same glide + scroll driver:
84
+
85
+ ```tsx
86
+ const { pathRef, scrollRef, staticPath } = useSmoothSeries({
87
+ values, tweenMs: 1000, scroll: true, width: 240, buildPath: buildMyGeometry,
88
+ });
89
+ ```
90
+
91
+ `buildPath` receives the true values **once per data change** (not per frame) and
92
+ returns the `d` string; only the scroll transform updates per frame. The velocity
93
+ is self-measured from the data cadence (see above), so hosts don't need an exact
94
+ `tweenMs`. Guards (reduced-motion fallback to instant, pause on `document.hidden`)
95
+ are built in; `staticPath` is the initial / reduced-motion render value.
96
+
97
+ Storybook: `apps/storybook/stories/ui-tools/visual/SmoothLine.stories.tsx`
@@ -0,0 +1,177 @@
1
+ // SmoothLine — the streaming sibling of Sparkline, now drawn on a CANVAS.
2
+ //
3
+ // The trace SCROLLS left at constant velocity via the `useSmoothSeries`
4
+ // wall-clock canvas engine: every rAF frame the whole monotone-cubic curve is
5
+ // redrawn at its scrolled X (one writer, one clock), so there is no SVG-era
6
+ // path/transform desync — the jump-then-catch-up is gone by construction.
7
+ //
8
+ // Geometry stays ours: the draw callback strokes a monotone-cubic (Fritsch–
9
+ // Carlson / d3 `curveMonotoneX`) polyline through the data points, with an
10
+ // optional area fill closed to the baseline. `tension={0}` degenerates to sharp
11
+ // straight segments.
12
+ //
13
+ // Color via the token bridge, resolved ONCE per theme change (not per frame):
14
+ // • a semantic token (`'primary' | 'success' | …`) → `useThemeColor()` → hex.
15
+ // • `'currentColor'` → the canvas inherits an ancestor's CSS `color` (a `text-*`
16
+ // className tint, e.g. cmdop's `text-chart-*`); we read the resolved color off
17
+ // the canvas element via `getComputedStyle(canvas).color` and pass it to
18
+ // `ctx.strokeStyle`. Re-resolved on theme change (the resolved-theme dep on
19
+ // `useThemeColor`'s own re-render path drives a redraw).
20
+ //
21
+ // Requires <UiProviders> mounted by the host app (no nested providers here).
22
+
23
+ 'use client';
24
+
25
+ import * as React from 'react';
26
+ import { cn } from '@djangocfg/ui-core/lib';
27
+ import { useThemeColor } from '@djangocfg/ui-core/styles/palette';
28
+ import { useResolvedTheme } from '@djangocfg/ui-core/hooks';
29
+ import type { SmoothLineProps } from './types';
30
+ import { useSmoothSeries, type DrawFn } from './useSmoothSeries';
31
+ import { DEFAULT_TENSION, type Pt, traceSmoothSegments } from './smoothPath';
32
+
33
+ /**
34
+ * Map oldest-first values to logical points (shared by the line + area draws).
35
+ *
36
+ * The Y-domain (min/max) is computed from the drawn values. Because the engine
37
+ * draws from a FIXED committed set between ticks (no per-frame Y-tween), the
38
+ * domain is stable across the whole scroll — the curve never rubber-bands.
39
+ */
40
+ function valuePoints(
41
+ values: readonly number[],
42
+ width: number,
43
+ height: number,
44
+ ): Pt[] {
45
+ const max = Math.max(...values, 1);
46
+ const min = Math.min(...values, 0);
47
+ const span = max - min || 1;
48
+ const stepX = values.length > 1 ? width / (values.length - 1) : width;
49
+ return values.map((v, i) => ({
50
+ x: i * stepX,
51
+ y: height - ((v - min) / span) * (height - 2) - 1,
52
+ }));
53
+ }
54
+
55
+ export function SmoothLine({
56
+ values,
57
+ color = 'primary',
58
+ width = 240,
59
+ height = 40,
60
+ strokeWidth = 1.5,
61
+ area = false,
62
+ tweenMs = 1000,
63
+ scroll = false,
64
+ delay = true,
65
+ tension = DEFAULT_TENSION,
66
+ ariaLabel,
67
+ className,
68
+ ...rest
69
+ }: SmoothLineProps) {
70
+ const isBridge = color === 'currentColor';
71
+ // Semantic token → hex; bridge mode reads the canvas's inherited CSS color.
72
+ const themeColor = useThemeColor(isBridge ? 'primary' : color);
73
+ // Re-resolve the bridge color when the theme flips (dark/light).
74
+ const resolvedTheme = useResolvedTheme();
75
+
76
+ // With the right-edge lag, the drawn set drops the freshest point so the right
77
+ // edge is always a known point; the engine's scroll reveals the off-edge value.
78
+ const useLag = scroll && delay && values.length > 2;
79
+ const drawn = React.useMemo(
80
+ () => (useLag ? [...values].slice(0, -1) : [...values]),
81
+ // values identity changes per tick; the joined key gates recompute.
82
+ // eslint-disable-next-line react-hooks/exhaustive-deps
83
+ [values.join(','), useLag],
84
+ );
85
+
86
+ // The per-frame canvas draw: monotone-cubic stroke (+ optional area), at the
87
+ // scrolled X. Rebuilt when the committed values / geometry / color change — so
88
+ // there is no per-frame Y-tween, only the scroll moves.
89
+ const draw = React.useCallback<DrawFn>(
90
+ (ctx, frame) => {
91
+ // Resolve the stroke/fill color ONCE per draw (not a per-point cost). In
92
+ // bridge mode read the inherited CSS color off the canvas; else the token.
93
+ let stroke = themeColor;
94
+ if (isBridge && ctx.canvas) {
95
+ const c = getComputedStyle(ctx.canvas).color;
96
+ if (c) stroke = c;
97
+ }
98
+
99
+ const w = frame.width;
100
+ const h = frame.height;
101
+ const sx = frame.scrollX;
102
+
103
+ if (drawn.length === 0) {
104
+ const mid = h / 2;
105
+ ctx.beginPath();
106
+ ctx.moveTo(0, mid);
107
+ ctx.lineTo(w, mid);
108
+ ctx.strokeStyle = stroke;
109
+ ctx.lineWidth = strokeWidth;
110
+ ctx.stroke();
111
+ return;
112
+ }
113
+
114
+ const pts = valuePoints(drawn, w, h).map((p) => ({
115
+ x: p.x + sx,
116
+ y: p.y,
117
+ }));
118
+
119
+ // Area fill first (under the stroke), hugging the same curve down to the
120
+ // baseline. Opacity 0.12 matches the old SVG fill.
121
+ if (area) {
122
+ ctx.beginPath();
123
+ ctx.moveTo(pts[0].x, h);
124
+ ctx.lineTo(pts[0].x, pts[0].y);
125
+ traceSmoothSegments(ctx, pts, tension);
126
+ ctx.lineTo(pts[pts.length - 1].x, h);
127
+ ctx.closePath();
128
+ ctx.globalAlpha = 0.12;
129
+ ctx.fillStyle = stroke;
130
+ ctx.fill();
131
+ ctx.globalAlpha = 1;
132
+ }
133
+
134
+ // Stroke the line over the fill.
135
+ ctx.beginPath();
136
+ ctx.moveTo(pts[0].x, pts[0].y);
137
+ traceSmoothSegments(ctx, pts, tension);
138
+ ctx.strokeStyle = stroke;
139
+ ctx.lineWidth = strokeWidth;
140
+ ctx.lineJoin = 'round';
141
+ ctx.lineCap = 'round';
142
+ ctx.stroke();
143
+ },
144
+ // resolvedTheme re-runs the bridge color read on a theme flip.
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
146
+ [drawn, area, tension, strokeWidth, themeColor, isBridge, resolvedTheme],
147
+ );
148
+
149
+ const { canvasRef } = useSmoothSeries({
150
+ values,
151
+ tweenMs,
152
+ scroll,
153
+ delay,
154
+ width,
155
+ height,
156
+ draw,
157
+ });
158
+
159
+ return (
160
+ <div
161
+ data-slot="smooth-line"
162
+ data-color={color}
163
+ className={cn('block w-full', className)}
164
+ style={isBridge ? undefined : { color: themeColor }}
165
+ {...rest}
166
+ >
167
+ <canvas
168
+ ref={canvasRef}
169
+ className="block w-full"
170
+ style={{ height, color: isBridge ? undefined : themeColor }}
171
+ role="img"
172
+ aria-label={ariaLabel}
173
+ aria-hidden={ariaLabel ? undefined : true}
174
+ />
175
+ </div>
176
+ );
177
+ }
@@ -0,0 +1,21 @@
1
+ export { SmoothLine } from './SmoothLine';
2
+ export {
3
+ useSmoothSeries,
4
+ useReducedMotion,
5
+ type DrawFn,
6
+ type DrawContext,
7
+ type SmoothSeriesConfig,
8
+ type SmoothSeriesHandles,
9
+ } from './useSmoothSeries';
10
+ export {
11
+ DEFAULT_TENSION,
12
+ smoothLinePath,
13
+ smoothSegments,
14
+ traceSmoothSegments,
15
+ type Pt,
16
+ } from './smoothPath';
17
+ export type {
18
+ SmoothLineProps,
19
+ SmoothLineColor,
20
+ SmoothSeriesOptions,
21
+ } from './types';
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+
3
+ import { createLazyComponent } from '../../../../common/lazy-wrapper';
4
+ import type { SmoothLineProps } from './types';
5
+
6
+ export const LazySmoothLine = createLazyComponent<SmoothLineProps>(
7
+ () => import('./SmoothLine').then((mod) => ({ default: mod.SmoothLine })),
8
+ {
9
+ displayName: 'LazySmoothLine',
10
+ fallback: (
11
+ <span
12
+ data-slot="smooth-line-skeleton"
13
+ className="block h-10 w-full animate-pulse rounded bg-muted"
14
+ />
15
+ ),
16
+ },
17
+ );
18
+
19
+ export type {
20
+ SmoothLineProps,
21
+ SmoothLineColor,
22
+ SmoothSeriesOptions,
23
+ } from './types';
@@ -0,0 +1,209 @@
1
+ // smoothPath — turn a polyline of points into a flowing SMOOTH curve.
2
+ //
3
+ // The straight-segment builders (`M x,y L x,y L x,y …`) draw the line as a
4
+ // jagged polyline: every data point is a sharp angle. This converts the same
5
+ // points into a MONOTONE-CUBIC spline (Fritsch–Carlson, == d3's curveMonotoneX)
6
+ // emitted as cubic-bezier segments (`M x,y C c1 c2 p …`), so the line flows in
7
+ // gentle curves through every point instead of kinking at each one.
8
+ //
9
+ // Why monotone-cubic and NOT Catmull-Rom (the previous curve):
10
+ // Catmull-Rom makes pretty curves but "often produces inappropriate OVERSHOOTS
11
+ // even for monotone data points" — it can shoot above a local max or below a
12
+ // local min, i.e. it WOBBLES between points and invents values the data never
13
+ // had. On a streaming chart those overshoots MOVE as the trailing control
14
+ // points change, adding shimmer on top of the scroll. Monotone-cubic
15
+ // (Fritsch–Carlson) guarantees "no overshoots beyond the given data points" —
16
+ // the interpolant stays within the data's own bounds, so the curve can never
17
+ // wobble or overshoot. It is the standard choice for data charts precisely
18
+ // because it doesn't fabricate between-point values. See
19
+ // en.wikipedia.org/wiki/Monotone_cubic_interpolation and d3-shape
20
+ // `curveMonotoneX`.
21
+ //
22
+ // The algorithm (2-pass Fritsch–Carlson):
23
+ // pass 1 — secant slopes between consecutive points, then a 3-point tangent at
24
+ // each interior point (the Catmull-Rom-ish average);
25
+ // pass 2 — clamp each tangent so it cannot exceed 3× the adjacent secant,
26
+ // which is exactly what enforces monotonicity (kills overshoot).
27
+ // The bezier control handles are then placed one third of the x-gap along each
28
+ // point's tangent — the standard Hermite→bezier conversion.
29
+ //
30
+ // Pure geometry: this is NOT animation. The streaming engine scrolls the whole
31
+ // `<g>`; this builds the curve once per data change from the true points.
32
+ // `tension` still exists for API compat (and `0` is the SHARP disable path), but
33
+ // it no longer scales overshoot — monotone tangents need no tension knob.
34
+
35
+ /** A point in viewBox space. */
36
+ export interface Pt {
37
+ x: number;
38
+ y: number;
39
+ }
40
+
41
+ /**
42
+ * Default smoothing tension. Retained for API compatibility — any value > 0
43
+ * selects the monotone-cubic curve (which has no tension parameter of its own);
44
+ * `0` degenerates to straight `L` segments for charts that need sharp corners
45
+ * (ECG QRS spikes, which keep their own bespoke builder rather than this one).
46
+ */
47
+ export const DEFAULT_TENSION = 0.5;
48
+
49
+ /**
50
+ * Build a smooth `d` path through `points` using monotone-cubic → cubic bezier.
51
+ *
52
+ * @param points anchor points, in draw order. The curve passes through each.
53
+ * @param tension `0` → straight (`L`) segments; any value > 0 → monotone curve.
54
+ * @param _bounds viewBox extent (kept for signature compat; monotone-cubic does
55
+ * not overshoot, so no clamp is needed).
56
+ * @returns the full `M…`-prefixed path. For the BODY only (no leading `M`), use
57
+ * {@link smoothSegments}.
58
+ */
59
+ export function smoothLinePath(
60
+ points: readonly Pt[],
61
+ tension: number,
62
+ _bounds?: { width: number; height: number },
63
+ ): string {
64
+ if (points.length === 0) return '';
65
+ const head = `${points[0].x.toFixed(2)},${points[0].y.toFixed(2)}`;
66
+ if (points.length === 1) return `M${head}`;
67
+ return `M${head} ${smoothSegments(points, tension)}`;
68
+ }
69
+
70
+ /**
71
+ * The curve BODY (no leading `M`) — the `C …`/`L …` segments that connect the
72
+ * points. Split out so the area builder can reuse the exact same curve between
73
+ * its own `M0,baseline` open and `L…baseline Z` close (the fill must hug the
74
+ * same flowing line as the stroke, not a straight re-trace).
75
+ *
76
+ * @param tension `0` → straight `L` segments (disable path); > 0 → monotone.
77
+ * @param _bounds ignored (monotone-cubic does not overshoot) — kept for compat.
78
+ */
79
+ export function smoothSegments(
80
+ points: readonly Pt[],
81
+ tension: number,
82
+ _bounds?: { width: number; height: number },
83
+ ): string {
84
+ if (points.length < 2) return '';
85
+
86
+ // tension <= 0 → straight polyline (the disable path; SHARP geometry).
87
+ if (tension <= 0) {
88
+ return points
89
+ .slice(1)
90
+ .map((p) => `L${p.x.toFixed(2)},${p.y.toFixed(2)}`)
91
+ .join(' ');
92
+ }
93
+
94
+ const m = monotoneTangents(points);
95
+ const out: string[] = [];
96
+
97
+ for (let i = 0; i < points.length - 1; i += 1) {
98
+ const p1 = points[i];
99
+ const p2 = points[i + 1];
100
+ const dx = (p2.x - p1.x) / 3;
101
+ // Hermite → cubic bezier: handles sit one third of the x-gap along each
102
+ // endpoint's tangent. Monotone tangents guarantee no overshoot, so no clamp.
103
+ const c1x = p1.x + dx;
104
+ const c1y = p1.y + dx * m[i];
105
+ const c2x = p2.x - dx;
106
+ const c2y = p2.y - dx * m[i + 1];
107
+ out.push(
108
+ `C${c1x.toFixed(2)},${c1y.toFixed(2)} ${c2x.toFixed(2)},${c2y.toFixed(2)} ${p2.x.toFixed(2)},${p2.y.toFixed(2)}`,
109
+ );
110
+ }
111
+
112
+ return out.join(' ');
113
+ }
114
+
115
+ /**
116
+ * Trace a monotone-cubic curve through `points` onto a Canvas2D context using the
117
+ * SAME Fritsch–Carlson tangents as {@link smoothSegments} — the canvas analogue
118
+ * of emitting the `C …`/`L …` body, via `bezierCurveTo`/`lineTo`. The caller owns
119
+ * `beginPath()` and the opening `moveTo` (so the area filler can open at the
120
+ * baseline and close after); this only appends the curve through the points.
121
+ *
122
+ * @param tension `0` → straight `lineTo` segments (the SHARP disable path); any
123
+ * value > 0 → the monotone curve.
124
+ */
125
+ export function traceSmoothSegments(
126
+ ctx: CanvasRenderingContext2D,
127
+ points: readonly Pt[],
128
+ tension: number,
129
+ ): void {
130
+ if (points.length < 2) return;
131
+
132
+ // tension <= 0 → straight polyline (the disable path; SHARP geometry).
133
+ if (tension <= 0) {
134
+ for (let i = 1; i < points.length; i += 1) {
135
+ ctx.lineTo(points[i].x, points[i].y);
136
+ }
137
+ return;
138
+ }
139
+
140
+ const m = monotoneTangents(points);
141
+ for (let i = 0; i < points.length - 1; i += 1) {
142
+ const p1 = points[i];
143
+ const p2 = points[i + 1];
144
+ const dx = (p2.x - p1.x) / 3;
145
+ // Hermite → cubic bezier: handles sit one third of the x-gap along each
146
+ // endpoint's tangent. Monotone tangents guarantee no overshoot, so no clamp.
147
+ ctx.bezierCurveTo(
148
+ p1.x + dx,
149
+ p1.y + dx * m[i],
150
+ p2.x - dx,
151
+ p2.y - dx * m[i + 1],
152
+ p2.x,
153
+ p2.y,
154
+ );
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Fritsch–Carlson monotone tangents (the slope dy/dx at each point). Two passes:
160
+ * a 3-point initial tangent, then a clamp that enforces monotonicity so the
161
+ * resulting cubic cannot overshoot the data between points. Mirrors d3-shape's
162
+ * `curveMonotoneX`.
163
+ */
164
+ function monotoneTangents(points: readonly Pt[]): number[] {
165
+ const n = points.length;
166
+ const tangents = new Array<number>(n).fill(0);
167
+ if (n < 2) return tangents;
168
+
169
+ // Secant slopes between consecutive points.
170
+ const secants = new Array<number>(n - 1);
171
+ for (let i = 0; i < n - 1; i += 1) {
172
+ const dx = points[i + 1].x - points[i].x;
173
+ secants[i] = dx === 0 ? 0 : (points[i + 1].y - points[i].y) / dx;
174
+ }
175
+
176
+ // Pass 1 — endpoint tangents = adjacent secant; interior = average of the two
177
+ // neighbouring secants (the 3-point tangent).
178
+ tangents[0] = secants[0];
179
+ tangents[n - 1] = secants[n - 2];
180
+ for (let i = 1; i < n - 1; i += 1) {
181
+ tangents[i] = (secants[i - 1] + secants[i]) / 2;
182
+ }
183
+
184
+ // Pass 2 — clamp tangents to enforce monotonicity (Fritsch–Carlson). Where a
185
+ // secant is flat, pin both surrounding tangents to 0 (a local extreme stays
186
+ // flat). Otherwise scale the tangent so (α, β) lie in the monotone circle of
187
+ // radius 3, which is what kills overshoot.
188
+ for (let i = 0; i < n - 1; i += 1) {
189
+ const s = secants[i];
190
+ if (s === 0) {
191
+ tangents[i] = 0;
192
+ tangents[i + 1] = 0;
193
+ continue;
194
+ }
195
+ const a = tangents[i] / s;
196
+ const b = tangents[i + 1] / s;
197
+ // If a tangent points the "wrong" way relative to the secant, flatten it.
198
+ if (a < 0) tangents[i] = 0;
199
+ if (b < 0) tangents[i + 1] = 0;
200
+ const h = a * a + b * b;
201
+ if (h > 9) {
202
+ const t = 3 / Math.sqrt(h);
203
+ tangents[i] = t * a * s;
204
+ tangents[i + 1] = t * b * s;
205
+ }
206
+ }
207
+
208
+ return tangents;
209
+ }
@@ -0,0 +1,98 @@
1
+ // SmoothLine — streaming sibling of Sparkline.
2
+ //
3
+ // Drawn on a CANVAS: a single wall-clock rAF loop redraws the whole line each
4
+ // frame at its scrolled X (one writer, one clock — the Smoothie/uPlot model), so
5
+ // the trace SCROLLS left at constant velocity with no SVG-era path/transform
6
+ // desync. Theme-color via the same token bridge as Sparkline.
7
+
8
+ import type * as React from 'react';
9
+
10
+ /**
11
+ * Color source for the stroke / area fill.
12
+ *
13
+ * - A semantic token (`'primary' | 'success' | …`) resolves at runtime via
14
+ * `useThemeColor()` from `@djangocfg/ui-core/styles/palette`.
15
+ * - The literal `'currentColor'` is the **token bridge**: the SVG inherits the
16
+ * CSS `color` of an ancestor, so a host can tint via a `text-*` className
17
+ * (e.g. cmdop's `text-chart-3`) without going through the semantic set.
18
+ */
19
+ export type SmoothLineColor =
20
+ | 'primary'
21
+ | 'success'
22
+ | 'warning'
23
+ | 'destructive'
24
+ | 'info'
25
+ | 'currentColor';
26
+
27
+ /**
28
+ * Options for {@link useSmoothSeries} (canvas engine). The seed cadence, the
29
+ * scroll toggle, and the right-edge delay.
30
+ */
31
+ export interface SmoothSeriesOptions {
32
+ /**
33
+ * Seed cadence (ms) — the first-frame guess + clamp band for the MEASURED
34
+ * scroll velocity, NOT a fixed slide speed. The engine EMAs the real
35
+ * inter-sample interval and drives the scroll off that, so a jittery feed still
36
+ * glides steadily. Stats charts tick ≈ 1000; the quality series (~30 s cadence)
37
+ * wants a longer window. @default 1000
38
+ */
39
+ tweenMs?: number;
40
+ /**
41
+ * Continuous left-scroll: the canvas redraws the whole trace each frame at its
42
+ * wall-clock-scrolled X (a hospital-monitor slide) instead of teleporting. Set
43
+ * for live windowed charts (ECG / traffic). @default false
44
+ */
45
+ scroll?: boolean;
46
+ /**
47
+ * Hold the right edge back by one interval (Smoothie / chartjs-streaming
48
+ * `delay`) so the newest point is known before it scrolls into view — a
49
+ * continuous right edge instead of "grow then snap". The off-edge point is
50
+ * clipped for free by the per-frame `clearRect`. Requires `scroll`.
51
+ * @default true
52
+ */
53
+ delay?: boolean;
54
+ }
55
+
56
+ export interface SmoothLineProps
57
+ extends Omit<React.ComponentProps<'div'>, 'children'> {
58
+ /** Series values, oldest-first. Empty renders a flat baseline. */
59
+ values: readonly number[];
60
+ /**
61
+ * Color token. A semantic token resolves via `useThemeColor()`; `'currentColor'`
62
+ * inherits the ancestor `text-*` tint (the cmdop bridge). @default 'primary'
63
+ */
64
+ color?: SmoothLineColor;
65
+ /** Logical chart width (CSS px). The canvas stretches to its container. @default 240 */
66
+ width?: number;
67
+ /** Logical chart height in CSS px. @default 40 */
68
+ height?: number;
69
+ /** Line stroke width. @default 1.5 */
70
+ strokeWidth?: number;
71
+ /** Render an alpha area fill under the line. @default false */
72
+ area?: boolean;
73
+ /** Wall-clock interval per slot (ms) — the scroll cadence. @default 1000 */
74
+ tweenMs?: number;
75
+ /** Continuous constant-velocity left-scroll for live windowed data. @default false */
76
+ scroll?: boolean;
77
+ /** One-interval right-edge lag (requires `scroll`). @default true */
78
+ delay?: boolean;
79
+ /**
80
+ * Curve smoothness (the line SHAPE, not the motion). The line is drawn as a
81
+ * MONOTONE-CUBIC curve (Fritsch–Carlson / d3 `curveMonotoneX`) through the
82
+ * points so it flows in gentle curves WITHOUT overshooting or inventing values
83
+ * between data points.
84
+ *
85
+ * - `0` → straight `L` segments (the angular sparkline look) — the disable
86
+ * path for charts that need SHARP geometry (e.g. ECG QRS spikes, which
87
+ * keep their own bespoke builder rather than this generic one).
88
+ * - any value > 0 → the monotone flowing curve (the default). Monotone-cubic
89
+ * has no tension parameter of its own, so values above 0 all select the
90
+ * same non-overshooting curve.
91
+ *
92
+ * This is pure geometry: the curve is rebuilt from the true points once per
93
+ * data change, then the whole trace scrolls. @default 0.5
94
+ */
95
+ tension?: number;
96
+ /** Optional accessible label. */
97
+ ariaLabel?: string;
98
+ }