@schandlergarcia/sf-web-components 1.9.37 → 1.9.39

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 (109) hide show
  1. package/package.json +4 -1
  2. package/scripts/postinstall.mjs +116 -65
  3. package/src/components/library/cards/ActionList.jsx +38 -0
  4. package/src/components/library/cards/ActivityCard.jsx +56 -0
  5. package/src/components/library/cards/BaseCard.jsx +109 -0
  6. package/src/components/library/cards/CalloutCard.jsx +37 -0
  7. package/src/components/library/cards/ChartCard.jsx +105 -0
  8. package/src/components/library/cards/FeedPanel.jsx +39 -0
  9. package/src/components/library/cards/ListCard.jsx +193 -0
  10. package/src/components/library/cards/MetricCard.jsx +109 -0
  11. package/src/components/library/cards/MetricsStrip.jsx +78 -0
  12. package/src/components/library/cards/SectionCard.jsx +83 -0
  13. package/src/components/library/cards/SemanticMetricCard.jsx +52 -0
  14. package/src/components/library/cards/SemanticMetricCardWithLoading.jsx +23 -0
  15. package/src/components/library/cards/SemanticTableCard.jsx +48 -0
  16. package/src/components/library/cards/SemanticTableCardWithLoading.jsx +22 -0
  17. package/src/components/library/cards/StatusCard.jsx +220 -0
  18. package/src/components/library/cards/TableCard.jsx +337 -0
  19. package/src/components/library/cards/WidgetCard.jsx +90 -0
  20. package/src/components/library/charts/D3Chart.jsx +109 -0
  21. package/src/components/library/charts/D3ChartTemplates.jsx +126 -0
  22. package/src/components/library/charts/GeoMap.jsx +293 -0
  23. package/src/components/library/chat/ChatBar.jsx +256 -0
  24. package/src/components/library/chat/ChatInput.jsx +89 -0
  25. package/src/components/library/chat/ChatMessage.jsx +178 -0
  26. package/src/components/library/chat/ChatMessageList.jsx +73 -0
  27. package/src/components/library/chat/ChatPanel.jsx +97 -0
  28. package/src/components/library/chat/ChatSuggestions.jsx +28 -0
  29. package/src/components/library/chat/ChatToolCall.jsx +100 -0
  30. package/src/components/library/chat/ChatTypingIndicator.jsx +23 -0
  31. package/src/components/library/chat/ChatWelcome.jsx +43 -0
  32. package/src/components/library/chat/index.jsx +10 -0
  33. package/src/components/library/chat/useChatState.jsx +130 -0
  34. package/src/components/library/data/DataModeProvider.jsx +67 -0
  35. package/src/components/library/data/DataModeToggle.jsx +36 -0
  36. package/src/components/library/data/chartDataProvider.jsx +61 -0
  37. package/src/components/library/data/filterUtils.jsx +141 -0
  38. package/src/components/library/data/useDataSource.jsx +33 -0
  39. package/src/components/library/data/usePageFilters.jsx +99 -0
  40. package/src/components/library/filters/FilterBar.jsx +95 -0
  41. package/src/components/library/filters/SearchFilter.jsx +36 -0
  42. package/src/components/library/filters/SelectFilter.jsx +55 -0
  43. package/src/components/library/filters/ToggleFilter.jsx +52 -0
  44. package/src/components/library/filters/index.jsx +4 -0
  45. package/src/components/library/forms/FormField.jsx +291 -0
  46. package/src/components/library/forms/FormModal.jsx +201 -0
  47. package/src/components/library/forms/FormRenderer.jsx +46 -0
  48. package/src/components/library/forms/FormSection.jsx +69 -0
  49. package/src/components/library/forms/index.jsx +5 -0
  50. package/src/components/library/forms/useFormState.jsx +165 -0
  51. package/src/components/library/heroui/Accordion.jsx +26 -0
  52. package/src/components/library/heroui/Alert.jsx +8 -0
  53. package/src/components/library/heroui/Badge.jsx +8 -0
  54. package/src/components/library/heroui/Breadcrumbs.jsx +22 -0
  55. package/src/components/library/heroui/Button.jsx +58 -0
  56. package/src/components/library/heroui/Card.jsx +8 -0
  57. package/src/components/library/heroui/Collapsible.jsx +42 -0
  58. package/src/components/library/heroui/DatePicker.jsx +34 -0
  59. package/src/components/library/heroui/Dialog.jsx +37 -0
  60. package/src/components/library/heroui/Drawer.jsx +32 -0
  61. package/src/components/library/heroui/Dropdown.jsx +28 -0
  62. package/src/components/library/heroui/Field.jsx +51 -0
  63. package/src/components/library/heroui/Input.jsx +6 -0
  64. package/src/components/library/heroui/Kbd.jsx +8 -0
  65. package/src/components/library/heroui/Meter.jsx +8 -0
  66. package/src/components/library/heroui/Modal.jsx +32 -0
  67. package/src/components/library/heroui/Pagination.jsx +8 -0
  68. package/src/components/library/heroui/Popover.jsx +64 -0
  69. package/src/components/library/heroui/ProgressBar.jsx +8 -0
  70. package/src/components/library/heroui/ProgressCircle.jsx +8 -0
  71. package/src/components/library/heroui/ScrollShadow.jsx +8 -0
  72. package/src/components/library/heroui/Select.jsx +37 -0
  73. package/src/components/library/heroui/Separator.jsx +8 -0
  74. package/src/components/library/heroui/Skeleton.jsx +8 -0
  75. package/src/components/library/heroui/Tabs.jsx +26 -0
  76. package/src/components/library/heroui/Toast.jsx +25 -0
  77. package/src/components/library/heroui/Toggle.jsx +14 -0
  78. package/src/components/library/heroui/Tooltip.jsx +21 -0
  79. package/src/components/library/index.jsx +146 -0
  80. package/src/components/library/layout/PageContainer.jsx +11 -0
  81. package/src/components/library/skeletons/CardSkeleton.jsx +30 -0
  82. package/src/components/library/theme/AppThemeProvider.jsx +67 -0
  83. package/src/components/library/theme/tokens.jsx +72 -0
  84. package/src/components/library/ui/Alert.jsx +80 -0
  85. package/src/components/library/ui/Avatar.jsx +44 -0
  86. package/src/components/library/ui/BreadcrumbExtras.tsx +120 -0
  87. package/src/components/library/ui/Button.jsx +61 -0
  88. package/src/components/library/ui/Card.jsx +117 -0
  89. package/src/components/library/ui/Checkbox.jsx +17 -0
  90. package/src/components/library/ui/Chip.jsx +38 -0
  91. package/src/components/library/ui/Collapsible.tsx +31 -0
  92. package/src/components/library/ui/Container.jsx +56 -0
  93. package/src/components/library/ui/DatePicker.tsx +34 -0
  94. package/src/components/library/ui/Dialog.tsx +141 -0
  95. package/src/components/library/ui/EmptyState.jsx +46 -0
  96. package/src/components/library/ui/Field.tsx +82 -0
  97. package/src/components/library/ui/FieldGroup.jsx +17 -0
  98. package/src/components/library/ui/Input.jsx +21 -0
  99. package/src/components/library/ui/Label.jsx +22 -0
  100. package/src/components/library/ui/PaginationExtras.tsx +142 -0
  101. package/src/components/library/ui/Popover.tsx +39 -0
  102. package/src/components/library/ui/Select.tsx +113 -0
  103. package/src/components/library/ui/Spinner.d.ts +10 -0
  104. package/src/components/library/ui/Spinner.jsx +64 -0
  105. package/src/components/library/ui/Text.jsx +46 -0
  106. package/src/components/library/ui/Toggle.jsx +42 -0
  107. package/src/components/workspace/ComponentRegistry.jsx +297 -0
  108. package/src/lib/index.ts +1 -0
  109. package/src/lib/utils.ts +6 -0
@@ -0,0 +1,109 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * Minimal D3 chart host:
5
+ * - Owns the <svg> element
6
+ * - Computes responsive dimensions from container size
7
+ * - Calls renderChart(svgEl, data, dims, options)
8
+ */
9
+ export default function D3Chart({
10
+ data,
11
+ renderChart,
12
+ options = {},
13
+ width,
14
+ height = 280,
15
+ responsive = false,
16
+ aspectRatio,
17
+ className = "",
18
+ style,
19
+ containerStyle,
20
+ svgStyle,
21
+ loading = false,
22
+ error,
23
+ ariaLabel = "Chart"
24
+ }) {
25
+ const containerRef = React.useRef(null);
26
+ const svgRef = React.useRef(null);
27
+ const [containerWidth, setContainerWidth] = React.useState(null);
28
+
29
+ React.useEffect(() => {
30
+ if (!responsive) return;
31
+ const el = containerRef.current;
32
+ if (!el) return;
33
+
34
+ const obs = new ResizeObserver((entries) => {
35
+ const w = entries?.[0]?.contentRect?.width;
36
+ if (typeof w === "number" && Number.isFinite(w)) setContainerWidth(w);
37
+ });
38
+ obs.observe(el);
39
+ return () => obs.disconnect();
40
+ }, [responsive]);
41
+
42
+ const computedWidth = responsive ? containerWidth : width;
43
+ const computedHeight = React.useMemo(() => {
44
+ if (!responsive) return height;
45
+ if (!containerWidth) return height;
46
+ if (aspectRatio && Number.isFinite(aspectRatio) && aspectRatio > 0) return containerWidth / aspectRatio;
47
+ return height;
48
+ }, [responsive, containerWidth, height, aspectRatio]);
49
+
50
+ React.useEffect(() => {
51
+ if (loading || error) return;
52
+ if (!renderChart) return;
53
+ const svgEl = svgRef.current;
54
+ if (!svgEl) return;
55
+
56
+ const dims = {
57
+ width: computedWidth ?? 0,
58
+ height: computedHeight ?? 0
59
+ };
60
+
61
+ // Avoid calling renderChart before we have a measurable width in responsive mode
62
+ if (responsive && (!dims.width || dims.width < 10)) return;
63
+ if (!dims.height || dims.height < 10) return;
64
+
65
+ renderChart(svgEl, data, dims, options);
66
+ }, [data, renderChart, options, computedWidth, computedHeight, responsive, loading, error]);
67
+
68
+ if (error) {
69
+ return (
70
+ <div
71
+ ref={containerRef}
72
+ className={["w-full rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900 dark:border-rose-900/40 dark:bg-rose-950/30 dark:text-rose-100", className]
73
+ .filter(Boolean)
74
+ .join(" ")}
75
+ style={containerStyle}
76
+ >
77
+ {String(error)}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ return (
83
+ <div
84
+ ref={containerRef}
85
+ className={["w-full", className].filter(Boolean).join(" ")}
86
+ style={{ ...containerStyle, position: "relative" }}
87
+ >
88
+ {loading ? (
89
+ <div className="h-full w-full rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
90
+ <div className="space-y-3">
91
+ <div className="h-4 w-1/3 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
92
+ <div className="h-44 w-full animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
93
+ </div>
94
+ </div>
95
+ ) : (
96
+ <svg
97
+ ref={svgRef}
98
+ role="img"
99
+ aria-label={ariaLabel}
100
+ width={computedWidth ?? width ?? "100%"}
101
+ height={computedHeight}
102
+ style={{ ...svgStyle, ...style, display: "block" }}
103
+ />
104
+ )}
105
+ </div>
106
+ );
107
+ }
108
+
109
+
@@ -0,0 +1,126 @@
1
+ import * as d3 from "d3";
2
+
3
+ function clear(svg) {
4
+ d3.select(svg).selectAll("*").remove();
5
+ }
6
+
7
+ export const D3ChartTemplates = {
8
+ lineChart(svg, data, dims, opts = {}) {
9
+ const {
10
+ xKey = "x",
11
+ yKey = "y",
12
+ margin = { top: 12, right: 16, bottom: 28, left: 40 },
13
+ stroke = "#6366F1",
14
+ strokeWidth = 2,
15
+ showAxes = true,
16
+ showGrid = true
17
+ } = opts;
18
+
19
+ clear(svg);
20
+
21
+ const width = dims.width;
22
+ const height = dims.height;
23
+ const innerW = Math.max(0, width - margin.left - margin.right);
24
+ const innerH = Math.max(0, height - margin.top - margin.bottom);
25
+
26
+ const g = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`).append("g").attr("transform", `translate(${margin.left},${margin.top})`);
27
+
28
+ const xs = data.map((d) => d?.[xKey]).filter((v) => v != null);
29
+ const ys = data.map((d) => d?.[yKey]).filter((v) => v != null);
30
+
31
+ const xDomain = d3.extent(xs);
32
+ const yDomain = d3.extent(ys);
33
+
34
+ const x = d3.scaleLinear().domain(xDomain).nice().range([0, innerW]);
35
+ const y = d3.scaleLinear().domain(yDomain).nice().range([innerH, 0]);
36
+
37
+ if (showGrid) {
38
+ g.append("g")
39
+ .attr("class", "grid")
40
+ .call(d3.axisLeft(y).ticks(5).tickSize(-innerW).tickFormat(""))
41
+ .call((grid) => grid.selectAll("line").attr("stroke", "currentColor").attr("opacity", 0.12))
42
+ .call((grid) => grid.selectAll("path").attr("stroke", "none"));
43
+ }
44
+
45
+ const line = d3
46
+ .line()
47
+ .x((d) => x(d[xKey]))
48
+ .y((d) => y(d[yKey]))
49
+ .defined((d) => d?.[xKey] != null && d?.[yKey] != null);
50
+
51
+ g.append("path")
52
+ .datum(data)
53
+ .attr("fill", "none")
54
+ .attr("stroke", stroke)
55
+ .attr("stroke-width", strokeWidth)
56
+ .attr("d", line);
57
+
58
+ if (showAxes) {
59
+ g.append("g")
60
+ .attr("transform", `translate(0,${innerH})`)
61
+ .call(d3.axisBottom(x).ticks(6))
62
+ .call((ax) => ax.selectAll("text").attr("font-size", 10));
63
+
64
+ g.append("g")
65
+ .call(d3.axisLeft(y).ticks(5))
66
+ .call((ax) => ax.selectAll("text").attr("font-size", 10));
67
+ }
68
+ },
69
+
70
+ groupedBarChart(svg, data, dims, opts = {}) {
71
+ const {
72
+ groups = [],
73
+ margin = { top: 20, right: 20, bottom: 40, left: 55 },
74
+ xKey = "x",
75
+ colors = ["#6366F1", "#CBD5E1"],
76
+ barRadius = 4,
77
+ yFormat = ",.0f",
78
+ showGrid = true,
79
+ } = opts;
80
+
81
+ clear(svg);
82
+
83
+ const width = dims.width;
84
+ const height = dims.height;
85
+ const innerW = Math.max(0, width - margin.left - margin.right);
86
+ const innerH = Math.max(0, height - margin.top - margin.bottom);
87
+
88
+ const sel = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`);
89
+ const g = sel.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
90
+
91
+ const groupKeys = groups.length ? groups : Object.keys(data[0] || {}).filter((k) => k !== xKey);
92
+
93
+ const x0 = d3.scaleBand().domain(data.map((d) => d[xKey])).range([0, innerW]).padding(0.3);
94
+ const x1 = d3.scaleBand().domain(groupKeys).range([0, x0.bandwidth()]).padding(0.05);
95
+ const yMax = d3.max(data, (d) => d3.max(groupKeys, (k) => d[k])) * 1.15;
96
+ const y = d3.scaleLinear().domain([0, yMax]).range([innerH, 0]);
97
+
98
+ g.append("g")
99
+ .attr("transform", `translate(0,${innerH})`)
100
+ .call(d3.axisBottom(x0).tickSize(0))
101
+ .call((ax) => ax.selectAll("text").attr("fill", "#94a3b8").attr("font-size", "11px"))
102
+ .call((ax) => ax.select(".domain").remove());
103
+
104
+ g.append("g")
105
+ .call(d3.axisLeft(y).ticks(5).tickFormat(d3.format(yFormat)).tickSize(showGrid ? -innerW : 0))
106
+ .call((ax) => ax.selectAll("text").attr("fill", "#94a3b8").attr("font-size", "10px"))
107
+ .call((ax) => ax.selectAll(".tick line").attr("stroke", "#e2e8f0").attr("stroke-dasharray", "2,2"))
108
+ .call((ax) => ax.select(".domain").remove());
109
+
110
+ const rows = g.selectAll(".bar-group").data(data).join("g")
111
+ .attr("class", "bar-group")
112
+ .attr("transform", (d) => `translate(${x0(d[xKey])},0)`);
113
+
114
+ groupKeys.forEach((key, i) => {
115
+ rows.append("rect")
116
+ .attr("x", x1(key))
117
+ .attr("y", (d) => y(d[key]))
118
+ .attr("width", x1.bandwidth())
119
+ .attr("height", (d) => innerH - y(d[key]))
120
+ .attr("rx", barRadius)
121
+ .attr("fill", colors[i % colors.length]);
122
+ });
123
+ },
124
+ };
125
+
126
+
@@ -0,0 +1,293 @@
1
+ import React, { useMemo, useRef, useEffect, useState, useCallback } from "react";
2
+ import * as d3 from "d3";
3
+ import { feature } from "topojson-client";
4
+ import world from "world-atlas/land-110m.json";
5
+
6
+ const land = feature(world, world.objects.land);
7
+
8
+ const PROJECTIONS = {
9
+ naturalEarth: d3.geoNaturalEarth1,
10
+ mercator: d3.geoMercator,
11
+ equirectangular: d3.geoEquirectangular,
12
+ };
13
+
14
+ const THEMES = {
15
+ dark: {
16
+ bg: "#050b15",
17
+ bgGradient: ["#0a1628", "#050b15"],
18
+ land: "#1a2d4a",
19
+ landStroke: "#2a4060",
20
+ sphere: "#1e3a5f",
21
+ graticule: "#162a45",
22
+ graticuleOpacity: 0.35,
23
+ markerActive: "#cbd5e1",
24
+ markerInactive: "#64748b",
25
+ label: "#cbd5e1",
26
+ labelInactive: "#64748b",
27
+ arc: "#818cf8",
28
+ arcHighlight: "#c4b5fd",
29
+ arcDanger: "#f87171",
30
+ dot: "#a5b4fc",
31
+ dotDanger: "#f87171",
32
+ overlayFill: "rgba(248,113,113,0.10)",
33
+ overlayStroke: "rgba(248,113,113,0.30)",
34
+ },
35
+ light: {
36
+ bg: "#f8fafc",
37
+ bgGradient: ["#f8fafc", "#f1f5f9"],
38
+ land: "#e2e8f0",
39
+ landStroke: "#cbd5e1",
40
+ sphere: "#cbd5e1",
41
+ graticule: "#e2e8f0",
42
+ graticuleOpacity: 0.6,
43
+ markerActive: "#334155",
44
+ markerInactive: "#94a3b8",
45
+ label: "#334155",
46
+ labelInactive: "#94a3b8",
47
+ arc: "#6366f1",
48
+ arcHighlight: "#4f46e5",
49
+ arcDanger: "#ef4444",
50
+ dot: "#6366f1",
51
+ dotDanger: "#ef4444",
52
+ overlayFill: "rgba(239,68,68,0.08)",
53
+ overlayStroke: "rgba(239,68,68,0.3)",
54
+ },
55
+ };
56
+
57
+ function buildProjection(type, width, height) {
58
+ const factory = PROJECTIONS[type] ?? PROJECTIONS.naturalEarth;
59
+ return factory().fitSize([width, height], { type: "Sphere" });
60
+ }
61
+
62
+ export default function GeoMap({
63
+ width = 960,
64
+ height = 480,
65
+ projection: projType = "naturalEarth",
66
+ theme = "dark",
67
+ markers = [],
68
+ arcs = [],
69
+ overlays = [],
70
+ selectedId = null,
71
+ onArcClick,
72
+ onMarkerClick,
73
+ zoomable = true,
74
+ minZoom = 1,
75
+ maxZoom = 8,
76
+ initialBounds = null,
77
+ className = "",
78
+ children,
79
+ }) {
80
+ const t = THEMES[theme] ?? THEMES.dark;
81
+ const svgRef = useRef(null);
82
+ const [transform, setTransform] = useState(d3.zoomIdentity);
83
+ const zoomRef = useRef(null);
84
+
85
+ const { proj, pathGen, graticulePath, spherePath, landPath } = useMemo(() => {
86
+ const proj = buildProjection(projType, width, height);
87
+ const pathGen = d3.geoPath(proj);
88
+ return {
89
+ proj,
90
+ pathGen,
91
+ graticulePath: pathGen(d3.geoGraticule10()),
92
+ spherePath: pathGen({ type: "Sphere" }),
93
+ landPath: pathGen(land),
94
+ };
95
+ }, [projType, width, height]);
96
+
97
+ // D3 zoom behavior
98
+ useEffect(() => {
99
+ if (!zoomable || !svgRef.current) return;
100
+ const svg = d3.select(svgRef.current);
101
+ const zoom = d3.zoom()
102
+ .scaleExtent([minZoom, maxZoom])
103
+ .on("zoom", (e) => setTransform(e.transform));
104
+ zoomRef.current = zoom;
105
+ svg.call(zoom);
106
+ return () => svg.on(".zoom", null);
107
+ }, [zoomable, minZoom, maxZoom]);
108
+
109
+ // Apply initial zoom to fit bounds (markers/region) on mount
110
+ const initialBoundsApplied = useRef(false);
111
+ useEffect(() => {
112
+ if (!initialBounds || initialBoundsApplied.current) return;
113
+ if (!svgRef.current || !zoomRef.current || !proj) return;
114
+ const { sw, ne, padding = 40 } = initialBounds; // sw=[lonMin,latMin], ne=[lonMax,latMax]
115
+ const p0 = proj(sw);
116
+ const p1 = proj(ne);
117
+ if (!p0 || !p1) return;
118
+ const bx0 = Math.min(p0[0], p1[0]);
119
+ const by0 = Math.min(p0[1], p1[1]);
120
+ const bx1 = Math.max(p0[0], p1[0]);
121
+ const by1 = Math.max(p0[1], p1[1]);
122
+ const bw = bx1 - bx0;
123
+ const bh = by1 - by0;
124
+ if (bw < 1 || bh < 1) return;
125
+ const scale = Math.min((width - padding * 2) / bw, (height - padding * 2) / bh);
126
+ const cx = (bx0 + bx1) / 2;
127
+ const cy = (by0 + by1) / 2;
128
+ const tx = width / 2 - cx * scale;
129
+ const ty = height / 2 - cy * scale;
130
+ const clampedScale = Math.max(minZoom, Math.min(maxZoom, scale));
131
+ const t = d3.zoomIdentity.translate(tx, ty).scale(clampedScale);
132
+ d3.select(svgRef.current).call(zoomRef.current.transform, t);
133
+ initialBoundsApplied.current = true;
134
+ }, [initialBounds, proj, width, height, minZoom, maxZoom]);
135
+
136
+ const resetZoom = useCallback(() => {
137
+ if (!svgRef.current || !zoomRef.current) return;
138
+ d3.select(svgRef.current)
139
+ .transition()
140
+ .duration(400)
141
+ .call(zoomRef.current.transform, d3.zoomIdentity);
142
+ }, []);
143
+
144
+ const isZoomed = transform.k !== 1 || transform.x !== 0 || transform.y !== 0;
145
+
146
+ const arcPaths = useMemo(() => {
147
+ const cache = {};
148
+ arcs.forEach(a => {
149
+ const key = `${a.from[0]},${a.from[1]}-${a.to[0]},${a.to[1]}`;
150
+ if (cache[key]) { a._path = cache[key]; return; }
151
+ const interp = d3.geoInterpolate(a.from, a.to);
152
+ const pts = [];
153
+ for (let i = 0; i <= 1; i += 0.02) {
154
+ const p = proj(interp(i));
155
+ if (p) pts.push(p);
156
+ }
157
+ const path = pts.length > 1 ? d3.line()(pts) : null;
158
+ cache[key] = path;
159
+ a._path = path;
160
+ });
161
+ return arcs;
162
+ }, [arcs, proj]);
163
+
164
+ const activeMarkerIds = useMemo(() => {
165
+ const s = new Set();
166
+ markers.forEach(m => { if (m.active) s.add(m.id); });
167
+ return s;
168
+ }, [markers]);
169
+
170
+ const txStr = `translate(${transform.x},${transform.y}) scale(${transform.k})`;
171
+ const invScale = 1 / transform.k;
172
+
173
+ return (
174
+ <div className={`relative overflow-hidden ${className}`} style={{ background: t.bg }}>
175
+ <svg ref={svgRef} viewBox={`0 0 ${width} ${height}`} className="h-full w-full" preserveAspectRatio="xMidYMid slice" style={zoomable ? { cursor: "grab" } : undefined}>
176
+ <defs>
177
+ <radialGradient id="geo-bg" cx="50%" cy="50%" r="55%">
178
+ <stop offset="0%" stopColor={t.bgGradient[0]} />
179
+ <stop offset="100%" stopColor={t.bgGradient[1]} />
180
+ </radialGradient>
181
+ <filter id="geo-glow" x="-50%" y="-50%" width="200%" height="200%">
182
+ <feGaussianBlur in="SourceGraphic" stdDeviation="3" result="b" />
183
+ <feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge>
184
+ </filter>
185
+ </defs>
186
+ <style>{`
187
+ .geo-arc{stroke-dasharray:8,6;animation:geo-flow 1.5s linear infinite}
188
+ @keyframes geo-flow{to{stroke-dashoffset:-14}}
189
+ .geo-dot{animation:geo-pulse 2s ease-in-out infinite}
190
+ @keyframes geo-pulse{0%,100%{opacity:1}50%{opacity:.5}}
191
+ `}</style>
192
+
193
+ <rect width={width} height={height} fill="url(#geo-bg)" />
194
+
195
+ {/* Zoomable content group */}
196
+ <g transform={txStr}>
197
+ <path d={spherePath} fill="none" stroke={t.sphere} strokeWidth={0.8 * invScale} />
198
+ <path d={landPath} fill={t.land} stroke={t.landStroke} strokeWidth={0.4 * invScale} />
199
+ <path d={graticulePath} fill="none" stroke={t.graticule} strokeWidth={0.3 * invScale} opacity={t.graticuleOpacity} />
200
+
201
+ {/* Overlay zones (disruptions, weather) */}
202
+ {overlays.map(o => {
203
+ const c = proj(o.center);
204
+ if (!c) return null;
205
+ const edge = proj([o.center[0] + (o.radius ?? 5), o.center[1]]);
206
+ const r = edge ? Math.abs(edge[0] - c[0]) : 30;
207
+ return (
208
+ <circle
209
+ key={o.id}
210
+ cx={c[0]} cy={c[1]} r={r}
211
+ fill={o.fill ?? t.overlayFill}
212
+ stroke={o.stroke ?? t.overlayStroke}
213
+ strokeWidth={1 * invScale}
214
+ strokeDasharray="5,5"
215
+ className="animate-pulse"
216
+ />
217
+ );
218
+ })}
219
+
220
+ {/* Arcs */}
221
+ {arcPaths.map(a => {
222
+ if (!a._path) return null;
223
+ const sel = selectedId === a.id;
224
+ const danger = a.danger;
225
+ return (
226
+ <path
227
+ key={`arc-${a.id}`}
228
+ d={a._path}
229
+ fill="none"
230
+ stroke={danger ? t.arcDanger : sel ? t.arcHighlight : a.color ?? t.arc}
231
+ strokeWidth={(sel ? 3 : 2) * invScale}
232
+ opacity={selectedId != null && !sel ? 0.15 : danger ? 0.85 : 0.65}
233
+ className="geo-arc cursor-pointer"
234
+ onClick={() => onArcClick?.(a)}
235
+ />
236
+ );
237
+ })}
238
+
239
+ {/* Markers */}
240
+ {markers.map(m => {
241
+ const p = proj([m.lon, m.lat]);
242
+ if (!p) return null;
243
+ const active = m.active ?? activeMarkerIds.has(m.id);
244
+ return (
245
+ <g key={m.id} className={onMarkerClick ? "cursor-pointer" : ""} onClick={() => onMarkerClick?.(m)}>
246
+ <circle cx={p[0]} cy={p[1]} r={(active ? 3 : 2) * invScale} fill={active ? t.markerActive : t.markerInactive} />
247
+ {m.label !== false && (
248
+ <text x={p[0] + 6 * invScale} y={p[1] + 4 * invScale} fontSize={8 * invScale} fill={active ? t.label : t.labelInactive} fontFamily="sans-serif" fontWeight={600}>
249
+ {m.label ?? m.id}
250
+ </text>
251
+ )}
252
+ </g>
253
+ );
254
+ })}
255
+
256
+ {/* Moving dots (flight positions) */}
257
+ {arcPaths.map(a => {
258
+ if (a.progress == null || a.progress <= 0) return null;
259
+ const interp = d3.geoInterpolate(a.from, a.to);
260
+ const p = proj(interp(Math.min(a.progress, 0.99)));
261
+ if (!p) return null;
262
+ const sel = selectedId === a.id;
263
+ return (
264
+ <circle
265
+ key={`dot-${a.id}`}
266
+ cx={p[0]} cy={p[1]}
267
+ r={(sel ? 6 : 4.5) * invScale}
268
+ fill={a.danger ? t.dotDanger : a.dotColor ?? t.dot}
269
+ filter="url(#geo-glow)"
270
+ className="geo-dot cursor-pointer"
271
+ opacity={selectedId != null && !sel ? 0.3 : 1}
272
+ onClick={() => onArcClick?.(a)}
273
+ />
274
+ );
275
+ })}
276
+
277
+ {/* Custom children get access to projection */}
278
+ {typeof children === "function" ? children({ proj, pathGen, theme: t, width, height, transform }) : children}
279
+ </g>
280
+ </svg>
281
+
282
+ {/* Reset zoom button */}
283
+ {zoomable && isZoomed && (
284
+ <button
285
+ onClick={resetZoom}
286
+ className="absolute bottom-3 right-3 rounded-lg border border-white/15 bg-black/50 px-2.5 py-1.5 text-[11px] font-medium text-white/80 backdrop-blur-md transition hover:bg-black/70 hover:text-white"
287
+ >
288
+ Reset view
289
+ </button>
290
+ )}
291
+ </div>
292
+ );
293
+ }