@olympusoss/canvas 2.6.19

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 (128) hide show
  1. package/package.json +179 -0
  2. package/src/components/atoms/README.md +11 -0
  3. package/src/components/atoms/aspect-ratio.tsx +32 -0
  4. package/src/components/atoms/avatar.tsx +98 -0
  5. package/src/components/atoms/badge.tsx +44 -0
  6. package/src/components/atoms/brand-mark.tsx +74 -0
  7. package/src/components/atoms/button.tsx +104 -0
  8. package/src/components/atoms/checkbox.tsx +63 -0
  9. package/src/components/atoms/flex-box.tsx +105 -0
  10. package/src/components/atoms/icon.tsx +34 -0
  11. package/src/components/atoms/input.tsx +91 -0
  12. package/src/components/atoms/label.tsx +41 -0
  13. package/src/components/atoms/logo.tsx +89 -0
  14. package/src/components/atoms/progress.tsx +55 -0
  15. package/src/components/atoms/radio-group.tsx +122 -0
  16. package/src/components/atoms/scroll-area.tsx +106 -0
  17. package/src/components/atoms/section.tsx +48 -0
  18. package/src/components/atoms/separator.tsx +45 -0
  19. package/src/components/atoms/skeleton.tsx +17 -0
  20. package/src/components/atoms/slider.tsx +93 -0
  21. package/src/components/atoms/switch.tsx +60 -0
  22. package/src/components/atoms/textarea.tsx +78 -0
  23. package/src/components/atoms/toggle.tsx +80 -0
  24. package/src/components/charts/activity-heatmap.tsx +96 -0
  25. package/src/components/charts/axes.tsx +21 -0
  26. package/src/components/charts/chart-container.tsx +195 -0
  27. package/src/components/charts/chart-legend.tsx +67 -0
  28. package/src/components/charts/chart-tooltip.tsx +161 -0
  29. package/src/components/charts/chart-types.tsx +49 -0
  30. package/src/components/charts/containers.tsx +11 -0
  31. package/src/components/charts/data.tsx +16 -0
  32. package/src/components/charts/details.tsx +25 -0
  33. package/src/components/charts/gauge.tsx +106 -0
  34. package/src/components/charts/grids.tsx +8 -0
  35. package/src/components/charts/index.ts +62 -0
  36. package/src/components/charts/labeled-bar-list.tsx +85 -0
  37. package/src/components/charts/references.tsx +8 -0
  38. package/src/components/charts/service-health-list.tsx +73 -0
  39. package/src/components/charts/sparkline.tsx +52 -0
  40. package/src/components/charts/stacked-bar.tsx +104 -0
  41. package/src/components/charts/text.tsx +10 -0
  42. package/src/components/charts/world-heat-map-inner.tsx +317 -0
  43. package/src/components/charts/world-heat-map.tsx +184 -0
  44. package/src/components/molecules/README.md +12 -0
  45. package/src/components/molecules/action-bar.tsx +73 -0
  46. package/src/components/molecules/activity-item.tsx +74 -0
  47. package/src/components/molecules/alert.tsx +80 -0
  48. package/src/components/molecules/animated-background.tsx +92 -0
  49. package/src/components/molecules/brand-lockup.tsx +48 -0
  50. package/src/components/molecules/breadcrumb.tsx +161 -0
  51. package/src/components/molecules/button-group.tsx +104 -0
  52. package/src/components/molecules/calendar.tsx +216 -0
  53. package/src/components/molecules/card.tsx +101 -0
  54. package/src/components/molecules/code-block.tsx +48 -0
  55. package/src/components/molecules/empty-state.tsx +55 -0
  56. package/src/components/molecules/error-state.tsx +42 -0
  57. package/src/components/molecules/field-display.tsx +35 -0
  58. package/src/components/molecules/input-otp.tsx +74 -0
  59. package/src/components/molecules/loading-state.tsx +36 -0
  60. package/src/components/molecules/notification-item.tsx +67 -0
  61. package/src/components/molecules/notification-list.tsx +45 -0
  62. package/src/components/molecules/number-badge.tsx +53 -0
  63. package/src/components/molecules/page-header.tsx +88 -0
  64. package/src/components/molecules/page-tabs.tsx +94 -0
  65. package/src/components/molecules/pagination.tsx +150 -0
  66. package/src/components/molecules/phone-input.tsx +200 -0
  67. package/src/components/molecules/search-bar.tsx +64 -0
  68. package/src/components/molecules/secret-field.tsx +158 -0
  69. package/src/components/molecules/section-card.tsx +91 -0
  70. package/src/components/molecules/stat-card.tsx +96 -0
  71. package/src/components/molecules/status-badge.tsx +42 -0
  72. package/src/components/molecules/stepper.tsx +96 -0
  73. package/src/components/molecules/table.tsx +157 -0
  74. package/src/components/molecules/toggle-group.tsx +145 -0
  75. package/src/components/molecules/tooltip.tsx +150 -0
  76. package/src/components/molecules/user-avatar-chip.tsx +71 -0
  77. package/src/components/organisms/README.md +14 -0
  78. package/src/components/organisms/accordion.tsx +149 -0
  79. package/src/components/organisms/alert-dialog.tsx +269 -0
  80. package/src/components/organisms/carousel.tsx +244 -0
  81. package/src/components/organisms/collapsible.tsx +69 -0
  82. package/src/components/organisms/command.tsx +143 -0
  83. package/src/components/organisms/context-menu.tsx +333 -0
  84. package/src/components/organisms/dashboard-grid.tsx +360 -0
  85. package/src/components/organisms/data-table.tsx +330 -0
  86. package/src/components/organisms/dialog.tsx +304 -0
  87. package/src/components/organisms/drawer.tsx +100 -0
  88. package/src/components/organisms/dropdown-menu.tsx +434 -0
  89. package/src/components/organisms/editors/code-editor.tsx +144 -0
  90. package/src/components/organisms/editors/index.ts +4 -0
  91. package/src/components/organisms/editors/markdown-editor.tsx +153 -0
  92. package/src/components/organisms/editors/markdown-renderer.ts +27 -0
  93. package/src/components/organisms/editors/prose-canvas-classes.ts +45 -0
  94. package/src/components/organisms/editors/rich-text-editor.tsx +126 -0
  95. package/src/components/organisms/editors/toolbar/md-toolbar.tsx +129 -0
  96. package/src/components/organisms/editors/toolbar/rte-toolbar.tsx +211 -0
  97. package/src/components/organisms/editors/toolbar/toolbar-shell.tsx +45 -0
  98. package/src/components/organisms/editors/use-codemirror-theme.ts +61 -0
  99. package/src/components/organisms/error-boundary.tsx +61 -0
  100. package/src/components/organisms/form.tsx +174 -0
  101. package/src/components/organisms/hover-card.tsx +114 -0
  102. package/src/components/organisms/menubar.tsx +491 -0
  103. package/src/components/organisms/navbar.tsx +101 -0
  104. package/src/components/organisms/navigation-menu.tsx +234 -0
  105. package/src/components/organisms/popover.tsx +144 -0
  106. package/src/components/organisms/resizable.tsx +39 -0
  107. package/src/components/organisms/schema-form.tsx +232 -0
  108. package/src/components/organisms/select.tsx +303 -0
  109. package/src/components/organisms/sheet.tsx +256 -0
  110. package/src/components/organisms/sidebar.tsx +1037 -0
  111. package/src/components/organisms/sonner.tsx +96 -0
  112. package/src/components/organisms/tabs.tsx +132 -0
  113. package/src/components/organisms/theme-provider.tsx +101 -0
  114. package/src/hooks/use-mobile.tsx +19 -0
  115. package/src/index.ts +547 -0
  116. package/src/lib/portal-container.tsx +35 -0
  117. package/src/lib/utils.ts +6 -0
  118. package/src/native.ts +23 -0
  119. package/src/tokens/colors.ts +91 -0
  120. package/src/tokens/index.ts +3 -0
  121. package/src/tokens/spacing.ts +55 -0
  122. package/src/tokens/typography.ts +27 -0
  123. package/styles/canvas.css +55 -0
  124. package/styles/dashboard-grid.css +47 -0
  125. package/styles/leaflet.css +13 -0
  126. package/styles/tokens.css +234 -0
  127. package/tailwind.config.ts +70 -0
  128. package/tsconfig.json +23 -0
@@ -0,0 +1,52 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "../../lib/utils";
4
+
5
+ export interface SparklineProps extends React.HTMLAttributes<HTMLDivElement> {
6
+ /** Values to plot. Each entry maps to a bar. */
7
+ data: number[];
8
+ /** Pixel height of the chart. */
9
+ height?: number;
10
+ /**
11
+ * CSS variable name (without leading `--`) used for the bar fill. Default
12
+ * `chart-1`. The variable should resolve to an HSL triplet for `hsl()`.
13
+ */
14
+ colorVar?: string;
15
+ /** Pixel gap between bars. */
16
+ gap?: number;
17
+ /** Caption rendered below the bars. */
18
+ caption?: React.ReactNode;
19
+ }
20
+
21
+ /**
22
+ * Pure-CSS mini bar chart for inline embedding in `<StatCard>` /
23
+ * `<SectionCard>`. Decorative — for live data, use a `<BarChart>` from the
24
+ * charts tier with proper axes and tooltips.
25
+ */
26
+ export const Sparkline = React.forwardRef<HTMLDivElement, SparklineProps>(
27
+ ({ data, height = 160, colorVar = "chart-1", gap = 4, caption, className, ...props }, ref) => {
28
+ const max = Math.max(1, ...data);
29
+ return (
30
+ <div ref={ref} className={cn("w-full", className)} {...props}>
31
+ <div className="flex w-full items-end" style={{ height, gap }}>
32
+ {data.map((value, i) => {
33
+ const pct = Math.max(0, Math.min(100, (value / max) * 100));
34
+ return (
35
+ <div
36
+ key={`bar-${i}-${value}`}
37
+ className="flex-1 rounded-sm"
38
+ style={{
39
+ height: `${pct}%`,
40
+ background: `hsl(var(--${colorVar}))`,
41
+ }}
42
+ aria-hidden
43
+ />
44
+ );
45
+ })}
46
+ </div>
47
+ {caption && <p className="mt-3 text-xs text-muted-foreground">{caption}</p>}
48
+ </div>
49
+ );
50
+ },
51
+ );
52
+ Sparkline.displayName = "Sparkline";
@@ -0,0 +1,104 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "../../lib/utils";
4
+
5
+ export interface StackedBarSegment {
6
+ /** Label shown in the legend. Used as the React key. */
7
+ label: string;
8
+ /** Numeric value (raw count or percentage). Segments size proportionally to the sum. */
9
+ value: number;
10
+ /**
11
+ * CSS variable name (without leading `--`) used for the segment fill. Default
12
+ * `chart-1`. Each segment can override; unspecified segments fall through
13
+ * to the wrapper's `defaultColorVar`.
14
+ */
15
+ colorVar?: string;
16
+ }
17
+
18
+ export interface StackedBarProps extends React.HTMLAttributes<HTMLDivElement> {
19
+ /** Segments rendered left-to-right with width proportional to `value`. */
20
+ segments: StackedBarSegment[];
21
+ /**
22
+ * Fallback CSS variable name for segments that omit `colorVar`. Default
23
+ * `chart-1`.
24
+ */
25
+ defaultColorVar?: string;
26
+ /** Pixel height of the bar pill. Default `10`. */
27
+ height?: number;
28
+ /** Show the legend list below the bar. Default `true`. */
29
+ showLegend?: boolean;
30
+ /** Format the per-segment value in the legend. Default `(v) => `${v}%``. */
31
+ valueFormatter?: (value: number) => string;
32
+ /** Caption rendered below the legend. */
33
+ caption?: React.ReactNode;
34
+ }
35
+
36
+ /**
37
+ * Horizontal stacked-percent bar with an optional swatch legend below. Segments
38
+ * are sized proportionally to their value's share of the total — pass raw
39
+ * counts or pre-computed percentages, the rendering is the same.
40
+ */
41
+ export const StackedBar = React.forwardRef<HTMLDivElement, StackedBarProps>(
42
+ (
43
+ {
44
+ segments,
45
+ defaultColorVar = "chart-1",
46
+ height = 10,
47
+ showLegend = true,
48
+ valueFormatter = (v) => `${v}%`,
49
+ caption,
50
+ className,
51
+ ...props
52
+ },
53
+ ref,
54
+ ) => {
55
+ const total = segments.reduce((sum, s) => sum + s.value, 0) || 1;
56
+ return (
57
+ <div ref={ref} className={cn("w-full", className)} {...props}>
58
+ <div
59
+ className="flex w-full overflow-hidden rounded-full bg-muted"
60
+ style={{ height }}
61
+ role="presentation"
62
+ >
63
+ {segments.map((seg) => {
64
+ const pct = (seg.value / total) * 100;
65
+ const colorVar = seg.colorVar ?? defaultColorVar;
66
+ return (
67
+ <div
68
+ key={seg.label}
69
+ style={{
70
+ width: `${pct}%`,
71
+ background: `hsl(var(--${colorVar}))`,
72
+ }}
73
+ title={`${seg.label}: ${valueFormatter(seg.value)}`}
74
+ aria-hidden
75
+ />
76
+ );
77
+ })}
78
+ </div>
79
+ {showLegend && (
80
+ <ul className="mt-3 flex flex-col gap-2">
81
+ {segments.map((seg) => {
82
+ const colorVar = seg.colorVar ?? defaultColorVar;
83
+ return (
84
+ <li key={seg.label} className="flex items-center gap-2.5 text-[13px]">
85
+ <span
86
+ className="size-2 shrink-0 rounded-full"
87
+ style={{ background: `hsl(var(--${colorVar}))` }}
88
+ aria-hidden
89
+ />
90
+ <span className="flex-1">{seg.label}</span>
91
+ <span className="font-mono text-xs text-muted-foreground">
92
+ {valueFormatter(seg.value)}
93
+ </span>
94
+ </li>
95
+ );
96
+ })}
97
+ </ul>
98
+ )}
99
+ {caption && <p className="mt-3 text-xs text-muted-foreground">{caption}</p>}
100
+ </div>
101
+ );
102
+ },
103
+ );
104
+ StackedBar.displayName = "StackedBar";
@@ -0,0 +1,10 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Text-rendering primitives. `Label` is aliased to `ChartLabel` to avoid
5
+ * collision with canvas's form `Label` atom.
6
+ *
7
+ * Re-exported via `export { … }` so TypeScript doesn't have to name internal
8
+ * Recharts prop types in the d.ts emit.
9
+ */
10
+ export { Label as ChartLabel, LabelList, Text } from "recharts";
@@ -0,0 +1,317 @@
1
+ "use client";
2
+
3
+ /* c8 ignore file -- Leaflet imports require real DOM measurements and are
4
+ * jsdom-incompatible. This file is covered structurally via the lazy
5
+ * boundary + loading shell + error-fallback in world-heat-map.test.tsx;
6
+ * the rendered Leaflet tree is verified visually in the docs. */
7
+
8
+ import * as React from "react";
9
+ import { CircleMarker, MapContainer, TileLayer, Tooltip } from "react-leaflet";
10
+
11
+ import { cn } from "../../lib/utils";
12
+ import { useTheme } from "../organisms/theme-provider";
13
+ import type { WorldHeatMapPoint, WorldHeatMapProps } from "./world-heat-map";
14
+
15
+ /**
16
+ * Client-only Leaflet tree. Loaded via `React.lazy()` from the orchestrator
17
+ * `world-heat-map.tsx` so neither `leaflet` nor `react-leaflet` lands in the
18
+ * main canvas bundle. SSR / loading / error are handled by the orchestrator —
19
+ * this file assumes a browser environment.
20
+ *
21
+ * Visual identity matches the deployed admin spec extracted from
22
+ * `https://admin.ciam.nannier.com/dashboard`:
23
+ * - CartoDB Basemaps (`dark_all` / `light_all`, `@2x` retina)
24
+ * - Hidden controls (zoomControl, attributionControl)
25
+ * - Triple-layered CircleMarker per point (glow / main / centre)
26
+ * - Log-scaled radius (`log(1+count) / log(1+maxCount)`)
27
+ *
28
+ * Consumers must import `@olympusoss/canvas/styles/leaflet.css` once at app
29
+ * entry — Leaflet's base stylesheet doesn't ship with this component.
30
+ */
31
+
32
+ const SCOPED_CSS = `
33
+ .world-heat-map .leaflet-container {
34
+ border-radius: inherit;
35
+ background: transparent;
36
+ font: inherit;
37
+ touch-action: none;
38
+ }
39
+ .world-heat-map .leaflet-control-attribution {
40
+ display: none;
41
+ }
42
+ .world-heat-map.world-heat-map--no-controls .leaflet-control-zoom {
43
+ display: none;
44
+ }
45
+ .world-heat-map .leaflet-control-zoom {
46
+ border: 1px solid hsl(var(--border) / 0.5) !important;
47
+ border-radius: 0.5rem !important;
48
+ overflow: hidden;
49
+ box-shadow: 0 2px 8px rgb(0 0 0 / 0.1) !important;
50
+ }
51
+ .world-heat-map .leaflet-control-zoom a {
52
+ background: hsl(var(--popover)) !important;
53
+ color: hsl(var(--popover-foreground)) !important;
54
+ border-bottom-color: hsl(var(--border) / 0.3) !important;
55
+ width: 32px !important;
56
+ height: 32px !important;
57
+ line-height: 32px !important;
58
+ font-size: 16px !important;
59
+ }
60
+ .world-heat-map .leaflet-control-zoom a:hover {
61
+ background: hsl(var(--accent)) !important;
62
+ color: hsl(var(--accent-foreground)) !important;
63
+ }
64
+ .whm-tooltip-popup {
65
+ background: hsl(var(--popover)) !important;
66
+ color: hsl(var(--popover-foreground)) !important;
67
+ border: 1px solid hsl(var(--border) / 0.5) !important;
68
+ border-radius: 0.5rem !important;
69
+ padding: 0.5rem 0.75rem !important;
70
+ font-size: 0.75rem !important;
71
+ font-family: inherit !important;
72
+ box-shadow: 0 4px 12px rgb(0 0 0 / 0.15) !important;
73
+ }
74
+ .whm-tooltip-popup::before {
75
+ border-top-color: hsl(var(--popover)) !important;
76
+ }
77
+ .whm-tooltip-label {
78
+ font-weight: 600;
79
+ font-size: 13px;
80
+ line-height: 1.2;
81
+ }
82
+ .whm-tooltip-count {
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 6px;
86
+ margin-top: 2px;
87
+ color: hsl(var(--muted-foreground));
88
+ font-variant-numeric: tabular-nums;
89
+ font-size: 12px;
90
+ }
91
+ .whm-tooltip-dot {
92
+ display: inline-block;
93
+ height: 8px;
94
+ width: 8px;
95
+ border-radius: 50%;
96
+ }
97
+ `;
98
+
99
+ const TILE_URL_DARK = "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png";
100
+ const TILE_URL_LIGHT = "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png";
101
+
102
+ const ATTRIBUTION =
103
+ '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>';
104
+
105
+ function resolveHeight(height: number | string | undefined): string {
106
+ if (height === undefined) return "100%";
107
+ return typeof height === "number" ? `${height}px` : height;
108
+ }
109
+
110
+ interface MarkerLayers {
111
+ glowRadius: number;
112
+ mainRadius: number;
113
+ centreRadius: number;
114
+ mainOpacity: number;
115
+ glowOpacity: number;
116
+ }
117
+
118
+ function deriveMarker(
119
+ count: number,
120
+ maxCount: number,
121
+ [rMin, rMax]: [number, number],
122
+ ): MarkerLayers {
123
+ const t = maxCount > 0 ? Math.log(1 + count) / Math.log(1 + maxCount) : 0;
124
+ const r = rMin + t * (rMax - rMin);
125
+ const opacity = 0.4 + t * 0.55;
126
+ return {
127
+ glowRadius: r * 1.8,
128
+ mainRadius: r,
129
+ centreRadius: Math.max(r * 0.35, 1.5),
130
+ mainOpacity: opacity,
131
+ glowOpacity: opacity * 0.15,
132
+ };
133
+ }
134
+
135
+ export default function WorldHeatMapInner({
136
+ points,
137
+ height,
138
+ center = [20, 0],
139
+ zoom = 3,
140
+ tileTheme = "auto",
141
+ showControls = false,
142
+ markerColor = "hsl(var(--chart-1))",
143
+ markerRadiusRange = [4, 20],
144
+ onMarkerClick,
145
+ showLegend = true,
146
+ title,
147
+ emptyState,
148
+ className,
149
+ }: WorldHeatMapProps) {
150
+ const { resolvedTheme } = useTheme();
151
+ const isDark = tileTheme === "dark" || (tileTheme === "auto" && resolvedTheme === "dark");
152
+ const tileUrl = isDark ? TILE_URL_DARK : TILE_URL_LIGHT;
153
+
154
+ const heightStr = resolveHeight(height);
155
+ const maxCount = React.useMemo(
156
+ () => points.reduce((acc, p) => Math.max(acc, p.count), 1),
157
+ [points],
158
+ );
159
+ const hasData = points.length > 0;
160
+
161
+ return (
162
+ <div
163
+ className={cn(
164
+ "relative overflow-hidden rounded-xl world-heat-map",
165
+ !showControls && "world-heat-map--no-controls",
166
+ className,
167
+ )}
168
+ style={{ height: heightStr }}
169
+ >
170
+ <style dangerouslySetInnerHTML={{ __html: SCOPED_CSS }} />
171
+
172
+ <MapContainer
173
+ center={center}
174
+ zoom={zoom}
175
+ minZoom={2}
176
+ maxZoom={19}
177
+ zoomControl={showControls}
178
+ attributionControl={showControls}
179
+ worldCopyJump={false}
180
+ maxBounds={[
181
+ [-85, -180],
182
+ [85, 180],
183
+ ]}
184
+ maxBoundsViscosity={1}
185
+ className="h-full w-full"
186
+ >
187
+ <TileLayer key={tileUrl} url={tileUrl} attribution={ATTRIBUTION} />
188
+
189
+ {points.map((point) => (
190
+ <MarkerGroup
191
+ key={`${point.lat},${point.lng},${point.label}`}
192
+ point={point}
193
+ maxCount={maxCount}
194
+ markerColor={markerColor}
195
+ markerRadiusRange={markerRadiusRange}
196
+ onMarkerClick={onMarkerClick}
197
+ />
198
+ ))}
199
+ </MapContainer>
200
+
201
+ {title && (
202
+ <div className="pointer-events-none absolute left-4 top-4 z-[1000] text-sm font-semibold tracking-tight text-foreground">
203
+ {title}
204
+ </div>
205
+ )}
206
+
207
+ {showLegend && hasData && (
208
+ <div className="pointer-events-none absolute bottom-3 left-4 z-[1000] flex items-center gap-2.5 text-[10px] text-muted-foreground">
209
+ <div className="flex items-center gap-1">
210
+ <svg width="8" height="8" aria-hidden="true">
211
+ <title>Smaller marker</title>
212
+ <circle cx="4" cy="4" r="2.5" fill={markerColor} fillOpacity={0.5} />
213
+ </svg>
214
+ <span>Fewer</span>
215
+ </div>
216
+ <div className="flex items-center gap-1">
217
+ <svg width="14" height="14" aria-hidden="true">
218
+ <title>Larger marker</title>
219
+ <circle cx="7" cy="7" r="5" fill={markerColor} fillOpacity={0.9} />
220
+ </svg>
221
+ <span>More</span>
222
+ </div>
223
+ </div>
224
+ )}
225
+
226
+ {!hasData && (
227
+ <div className="pointer-events-none absolute inset-0 z-[1000] flex items-center justify-center">
228
+ {emptyState ?? (
229
+ <div className="rounded-lg bg-card px-4 py-2 text-xs text-muted-foreground shadow">
230
+ No geographic data available
231
+ </div>
232
+ )}
233
+ </div>
234
+ )}
235
+
236
+ {/* CartoDB attribution — always visible per TOS, even when controls are hidden */}
237
+ {!showControls && (
238
+ <div
239
+ className="pointer-events-auto absolute bottom-1 right-1 z-[1000] text-[9px] text-muted-foreground/60"
240
+ dangerouslySetInnerHTML={{ __html: ATTRIBUTION }}
241
+ />
242
+ )}
243
+ </div>
244
+ );
245
+ }
246
+
247
+ /**
248
+ * Three concentric `<CircleMarker>`s per data point: outer glow, main dot
249
+ * (interactive + tooltip), bright centre. Pulled into its own component so the
250
+ * tooltip's stable `<Tooltip>` child slot lives next to the marker that owns
251
+ * it (react-leaflet attaches by parent identity).
252
+ */
253
+ function MarkerGroup({
254
+ point,
255
+ maxCount,
256
+ markerColor,
257
+ markerRadiusRange,
258
+ onMarkerClick,
259
+ }: {
260
+ point: WorldHeatMapPoint;
261
+ maxCount: number;
262
+ markerColor: string;
263
+ markerRadiusRange: [number, number];
264
+ onMarkerClick?: (point: WorldHeatMapPoint) => void;
265
+ }) {
266
+ const layers = deriveMarker(point.count, maxCount, markerRadiusRange);
267
+ const handlers = React.useMemo(
268
+ () => (onMarkerClick ? { click: () => onMarkerClick(point) } : undefined),
269
+ [onMarkerClick, point],
270
+ );
271
+
272
+ return (
273
+ <>
274
+ <CircleMarker
275
+ center={[point.lat, point.lng]}
276
+ radius={layers.glowRadius}
277
+ pathOptions={{
278
+ fillColor: markerColor,
279
+ fillOpacity: layers.glowOpacity,
280
+ stroke: false,
281
+ interactive: false,
282
+ }}
283
+ />
284
+ <CircleMarker
285
+ center={[point.lat, point.lng]}
286
+ radius={layers.mainRadius}
287
+ pathOptions={{
288
+ fillColor: markerColor,
289
+ fillOpacity: layers.mainOpacity,
290
+ color: markerColor,
291
+ weight: 1,
292
+ opacity: 0.8,
293
+ }}
294
+ eventHandlers={handlers}
295
+ >
296
+ <Tooltip direction="top" offset={[0, -layers.mainRadius]} className="whm-tooltip-popup">
297
+ <div className="whm-tooltip-label">{point.label}</div>
298
+ <div className="whm-tooltip-count">
299
+ <span className="whm-tooltip-dot" style={{ background: markerColor }} />
300
+ {point.count.toLocaleString()} session
301
+ {point.count !== 1 ? "s" : ""}
302
+ </div>
303
+ </Tooltip>
304
+ </CircleMarker>
305
+ <CircleMarker
306
+ center={[point.lat, point.lng]}
307
+ radius={layers.centreRadius}
308
+ pathOptions={{
309
+ fillColor: markerColor,
310
+ fillOpacity: 0.95,
311
+ stroke: false,
312
+ interactive: false,
313
+ }}
314
+ />
315
+ </>
316
+ );
317
+ }
@@ -0,0 +1,184 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "../../lib/utils";
6
+
7
+ /**
8
+ * `<WorldHeatMap>` — Leaflet-based geographic scatter / heat-map.
9
+ *
10
+ * Public surface lives here; the actual react-leaflet tree is in
11
+ * `world-heat-map-inner.tsx` and is loaded via `React.lazy()` so neither
12
+ * `leaflet` nor `react-leaflet` lands in canvas's main bundle. Both are
13
+ * declared as **peer-optional** in `package.json` — consumers who use the map
14
+ * install them explicitly; consumers who don't, don't pay.
15
+ *
16
+ * Consumers must also import the Leaflet base stylesheet at app entry:
17
+ *
18
+ * import "@olympusoss/canvas/styles/leaflet.css";
19
+ *
20
+ * Visual identity matches the deployed admin dashboard reference (CartoDB
21
+ * tiles, hidden controls, triple-layered CircleMarker per point).
22
+ */
23
+
24
+ // ── Public types ─────────────────────────────────────────────
25
+
26
+ export interface WorldHeatMapPoint {
27
+ /** Latitude. */
28
+ lat: number;
29
+ /** Longitude. */
30
+ lng: number;
31
+ /** Tooltip label (e.g. "Munich, DE"). */
32
+ label: string;
33
+ /** Drives marker radius. */
34
+ count: number;
35
+ }
36
+
37
+ export interface WorldHeatMapProps {
38
+ /** Geo-located points to render. Empty array shows the empty-state overlay. */
39
+ points: WorldHeatMapPoint[];
40
+ /** Container height. Number → px, string passes through. Default `"100%"`. */
41
+ height?: number | string;
42
+ /** Initial map center `[lat, lng]`. Default `[20, 0]`. */
43
+ center?: [number, number];
44
+ /** Initial zoom. Default `3`. */
45
+ zoom?: number;
46
+ /** Tile basemap. `"auto"` follows canvas's `useTheme()`. Default `"auto"`. */
47
+ tileTheme?: "auto" | "dark" | "light";
48
+ /** Show Leaflet zoom + attribution controls. Default `false`. */
49
+ showControls?: boolean;
50
+ /** Marker fill (any CSS colour, including `hsl(var(--chart-1))`). Default `"hsl(var(--chart-1))"`. */
51
+ markerColor?: string;
52
+ /** `[min, max]` marker radius in pixels (log-scaled on `count`). Default `[4, 20]`. */
53
+ markerRadiusRange?: [number, number];
54
+ /** Per-marker click handler. */
55
+ onMarkerClick?: (point: WorldHeatMapPoint) => void;
56
+ /** Show "Fewer / More" legend overlay (bottom-left). Default `true`. */
57
+ showLegend?: boolean;
58
+ /** Optional title overlaid top-left. */
59
+ title?: string;
60
+ /** Override the empty-state node. Default: a small "No geographic data available" card. */
61
+ emptyState?: React.ReactNode;
62
+ /** Pass-through className on the outer `.world-heat-map` wrapper. */
63
+ className?: string;
64
+ }
65
+
66
+ // ── Lazy-loaded inner ───────────────────────────────────────
67
+
68
+ const WorldHeatMapInner = React.lazy(() => import("./world-heat-map-inner"));
69
+
70
+ // ── Error boundary (peer-missing or runtime-load failure) ───
71
+
72
+ interface WorldHeatMapErrorBoundaryProps {
73
+ fallback: React.ReactNode;
74
+ children: React.ReactNode;
75
+ }
76
+ interface WorldHeatMapErrorBoundaryState {
77
+ hasError: boolean;
78
+ }
79
+
80
+ class WorldHeatMapErrorBoundary extends React.Component<
81
+ WorldHeatMapErrorBoundaryProps,
82
+ WorldHeatMapErrorBoundaryState
83
+ > {
84
+ state: WorldHeatMapErrorBoundaryState = { hasError: false };
85
+
86
+ /* c8 ignore start -- only triggers when the lazy-loaded Leaflet inner throws (peer missing or runtime load failure); both are jsdom-incompatible to simulate */
87
+ static getDerivedStateFromError(): WorldHeatMapErrorBoundaryState {
88
+ return { hasError: true };
89
+ }
90
+
91
+ render(): React.ReactNode {
92
+ return this.state.hasError ? this.props.fallback : this.props.children;
93
+ }
94
+ /* c8 ignore stop */
95
+ }
96
+
97
+ // ── Shell (SSR / loading / error) ───────────────────────────
98
+
99
+ type ShellState = "loading" | "error";
100
+
101
+ interface ShellProps {
102
+ height?: number | string;
103
+ className?: string;
104
+ state: ShellState;
105
+ }
106
+
107
+ function resolveHeight(height: number | string | undefined): string {
108
+ if (height === undefined) return "100%";
109
+ return typeof height === "number" ? `${height}px` : height;
110
+ }
111
+
112
+ function Shell({ height, className, state }: ShellProps) {
113
+ const heightStr = resolveHeight(height);
114
+ return (
115
+ <div
116
+ className={cn(
117
+ "relative flex items-center justify-center overflow-hidden rounded-xl bg-muted/40 text-xs text-muted-foreground world-heat-map",
118
+ className,
119
+ )}
120
+ style={{ height: heightStr }}
121
+ >
122
+ {state === "loading" ? (
123
+ <>
124
+ <svg
125
+ width="20"
126
+ height="20"
127
+ viewBox="0 0 20 20"
128
+ aria-hidden="true"
129
+ className="mr-2 animate-spin opacity-50"
130
+ >
131
+ <title>Loading</title>
132
+ <circle
133
+ cx="10"
134
+ cy="10"
135
+ r="8"
136
+ fill="none"
137
+ stroke="currentColor"
138
+ strokeWidth="2"
139
+ strokeDasharray="40"
140
+ strokeLinecap="round"
141
+ />
142
+ </svg>
143
+ Loading map…
144
+ </>
145
+ ) : (
146
+ <div className="px-4 py-2 text-center">
147
+ Map dependencies missing — install <code>leaflet</code> and <code>react-leaflet</code>.
148
+ </div>
149
+ )}
150
+ </div>
151
+ );
152
+ }
153
+
154
+ // ── Public component ────────────────────────────────────────
155
+
156
+ export function WorldHeatMap(props: WorldHeatMapProps) {
157
+ const [mounted, setMounted] = React.useState(false);
158
+
159
+ React.useEffect(() => {
160
+ setMounted(true);
161
+ }, []);
162
+
163
+ // SSR / hydration guard — prevents `window`/`document` access during the
164
+ // server pass and the first client render before hydration completes.
165
+ if (!mounted) {
166
+ return <Shell height={props.height} className={props.className} state="loading" />;
167
+ }
168
+
169
+ /* c8 ignore start -- post-mount tree mounts the lazy Leaflet inner; jsdom can't run Leaflet so the Suspense + ErrorBoundary tree is verified visually in the docs */
170
+ return (
171
+ <WorldHeatMapErrorBoundary
172
+ fallback={<Shell height={props.height} className={props.className} state="error" />}
173
+ >
174
+ <React.Suspense
175
+ fallback={<Shell height={props.height} className={props.className} state="loading" />}
176
+ >
177
+ <WorldHeatMapInner {...props} />
178
+ </React.Suspense>
179
+ </WorldHeatMapErrorBoundary>
180
+ );
181
+ /* c8 ignore stop */
182
+ }
183
+
184
+ WorldHeatMap.displayName = "WorldHeatMap";
@@ -0,0 +1,12 @@
1
+ # Molecules
2
+
3
+ Small compositions of atoms. Meaningful UI semantics. No app-state model.
4
+
5
+ **Can import**: `tokens/`, `lib/utils`, `atoms/`, React.
6
+
7
+ **Cannot import**: anything from `organisms/` or `templates/`.
8
+
9
+ Molecules don't own interactive state more complex than a local toggle. If it
10
+ has open/close state, selection, or form integration, it's an organism.
11
+
12
+ See [CONTRIBUTING.md](../../../CONTRIBUTING.md) for the full atomic-design rules.