@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.
- package/package.json +4 -1
- package/scripts/postinstall.mjs +116 -65
- package/src/components/library/cards/ActionList.jsx +38 -0
- package/src/components/library/cards/ActivityCard.jsx +56 -0
- package/src/components/library/cards/BaseCard.jsx +109 -0
- package/src/components/library/cards/CalloutCard.jsx +37 -0
- package/src/components/library/cards/ChartCard.jsx +105 -0
- package/src/components/library/cards/FeedPanel.jsx +39 -0
- package/src/components/library/cards/ListCard.jsx +193 -0
- package/src/components/library/cards/MetricCard.jsx +109 -0
- package/src/components/library/cards/MetricsStrip.jsx +78 -0
- package/src/components/library/cards/SectionCard.jsx +83 -0
- package/src/components/library/cards/SemanticMetricCard.jsx +52 -0
- package/src/components/library/cards/SemanticMetricCardWithLoading.jsx +23 -0
- package/src/components/library/cards/SemanticTableCard.jsx +48 -0
- package/src/components/library/cards/SemanticTableCardWithLoading.jsx +22 -0
- package/src/components/library/cards/StatusCard.jsx +220 -0
- package/src/components/library/cards/TableCard.jsx +337 -0
- package/src/components/library/cards/WidgetCard.jsx +90 -0
- package/src/components/library/charts/D3Chart.jsx +109 -0
- package/src/components/library/charts/D3ChartTemplates.jsx +126 -0
- package/src/components/library/charts/GeoMap.jsx +293 -0
- package/src/components/library/chat/ChatBar.jsx +256 -0
- package/src/components/library/chat/ChatInput.jsx +89 -0
- package/src/components/library/chat/ChatMessage.jsx +178 -0
- package/src/components/library/chat/ChatMessageList.jsx +73 -0
- package/src/components/library/chat/ChatPanel.jsx +97 -0
- package/src/components/library/chat/ChatSuggestions.jsx +28 -0
- package/src/components/library/chat/ChatToolCall.jsx +100 -0
- package/src/components/library/chat/ChatTypingIndicator.jsx +23 -0
- package/src/components/library/chat/ChatWelcome.jsx +43 -0
- package/src/components/library/chat/index.jsx +10 -0
- package/src/components/library/chat/useChatState.jsx +130 -0
- package/src/components/library/data/DataModeProvider.jsx +67 -0
- package/src/components/library/data/DataModeToggle.jsx +36 -0
- package/src/components/library/data/chartDataProvider.jsx +61 -0
- package/src/components/library/data/filterUtils.jsx +141 -0
- package/src/components/library/data/useDataSource.jsx +33 -0
- package/src/components/library/data/usePageFilters.jsx +99 -0
- package/src/components/library/filters/FilterBar.jsx +95 -0
- package/src/components/library/filters/SearchFilter.jsx +36 -0
- package/src/components/library/filters/SelectFilter.jsx +55 -0
- package/src/components/library/filters/ToggleFilter.jsx +52 -0
- package/src/components/library/filters/index.jsx +4 -0
- package/src/components/library/forms/FormField.jsx +291 -0
- package/src/components/library/forms/FormModal.jsx +201 -0
- package/src/components/library/forms/FormRenderer.jsx +46 -0
- package/src/components/library/forms/FormSection.jsx +69 -0
- package/src/components/library/forms/index.jsx +5 -0
- package/src/components/library/forms/useFormState.jsx +165 -0
- package/src/components/library/heroui/Accordion.jsx +26 -0
- package/src/components/library/heroui/Alert.jsx +8 -0
- package/src/components/library/heroui/Badge.jsx +8 -0
- package/src/components/library/heroui/Breadcrumbs.jsx +22 -0
- package/src/components/library/heroui/Button.jsx +58 -0
- package/src/components/library/heroui/Card.jsx +8 -0
- package/src/components/library/heroui/Collapsible.jsx +42 -0
- package/src/components/library/heroui/DatePicker.jsx +34 -0
- package/src/components/library/heroui/Dialog.jsx +37 -0
- package/src/components/library/heroui/Drawer.jsx +32 -0
- package/src/components/library/heroui/Dropdown.jsx +28 -0
- package/src/components/library/heroui/Field.jsx +51 -0
- package/src/components/library/heroui/Input.jsx +6 -0
- package/src/components/library/heroui/Kbd.jsx +8 -0
- package/src/components/library/heroui/Meter.jsx +8 -0
- package/src/components/library/heroui/Modal.jsx +32 -0
- package/src/components/library/heroui/Pagination.jsx +8 -0
- package/src/components/library/heroui/Popover.jsx +64 -0
- package/src/components/library/heroui/ProgressBar.jsx +8 -0
- package/src/components/library/heroui/ProgressCircle.jsx +8 -0
- package/src/components/library/heroui/ScrollShadow.jsx +8 -0
- package/src/components/library/heroui/Select.jsx +37 -0
- package/src/components/library/heroui/Separator.jsx +8 -0
- package/src/components/library/heroui/Skeleton.jsx +8 -0
- package/src/components/library/heroui/Tabs.jsx +26 -0
- package/src/components/library/heroui/Toast.jsx +25 -0
- package/src/components/library/heroui/Toggle.jsx +14 -0
- package/src/components/library/heroui/Tooltip.jsx +21 -0
- package/src/components/library/index.jsx +146 -0
- package/src/components/library/layout/PageContainer.jsx +11 -0
- package/src/components/library/skeletons/CardSkeleton.jsx +30 -0
- package/src/components/library/theme/AppThemeProvider.jsx +67 -0
- package/src/components/library/theme/tokens.jsx +72 -0
- package/src/components/library/ui/Alert.jsx +80 -0
- package/src/components/library/ui/Avatar.jsx +44 -0
- package/src/components/library/ui/BreadcrumbExtras.tsx +120 -0
- package/src/components/library/ui/Button.jsx +61 -0
- package/src/components/library/ui/Card.jsx +117 -0
- package/src/components/library/ui/Checkbox.jsx +17 -0
- package/src/components/library/ui/Chip.jsx +38 -0
- package/src/components/library/ui/Collapsible.tsx +31 -0
- package/src/components/library/ui/Container.jsx +56 -0
- package/src/components/library/ui/DatePicker.tsx +34 -0
- package/src/components/library/ui/Dialog.tsx +141 -0
- package/src/components/library/ui/EmptyState.jsx +46 -0
- package/src/components/library/ui/Field.tsx +82 -0
- package/src/components/library/ui/FieldGroup.jsx +17 -0
- package/src/components/library/ui/Input.jsx +21 -0
- package/src/components/library/ui/Label.jsx +22 -0
- package/src/components/library/ui/PaginationExtras.tsx +142 -0
- package/src/components/library/ui/Popover.tsx +39 -0
- package/src/components/library/ui/Select.tsx +113 -0
- package/src/components/library/ui/Spinner.d.ts +10 -0
- package/src/components/library/ui/Spinner.jsx +64 -0
- package/src/components/library/ui/Text.jsx +46 -0
- package/src/components/library/ui/Toggle.jsx +42 -0
- package/src/components/workspace/ComponentRegistry.jsx +297 -0
- package/src/lib/index.ts +1 -0
- 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
|
+
}
|