@kanaries/graphic-walker 0.4.1 → 0.4.3
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/dist/App.d.ts +5 -1
- package/dist/assets/buildMetricTable.worker-5555966a.js.map +1 -0
- package/dist/components/leafletRenderer/ChoroplethRenderer.d.ts +22 -0
- package/dist/components/leafletRenderer/POIRenderer.d.ts +20 -0
- package/dist/components/leafletRenderer/encodings.d.ts +7 -0
- package/dist/components/leafletRenderer/geoConfigPanel.d.ts +3 -0
- package/dist/components/leafletRenderer/index.d.ts +15 -0
- package/dist/components/leafletRenderer/tooltip.d.ts +9 -0
- package/dist/components/leafletRenderer/utils.d.ts +2 -0
- package/dist/components/pivotTable/index.d.ts +2 -1
- package/dist/components/pivotTable/inteface.d.ts +6 -2
- package/dist/components/pivotTable/leftTree.d.ts +1 -0
- package/dist/components/pivotTable/topTree.d.ts +2 -0
- package/dist/components/pivotTable/utils.d.ts +1 -2
- package/dist/config.d.ts +3 -2
- package/dist/graphic-walker.es.js +37811 -30386
- package/dist/graphic-walker.es.js.map +1 -1
- package/dist/graphic-walker.umd.js +145 -137
- package/dist/graphic-walker.umd.js.map +1 -1
- package/dist/interfaces.d.ts +28 -0
- package/dist/renderer/specRenderer.d.ts +2 -1
- package/dist/services.d.ts +7 -1
- package/dist/store/commonStore.d.ts +6 -0
- package/dist/store/visualSpecStore.d.ts +180 -4
- package/dist/utils/save.d.ts +1 -0
- package/dist/workers/buildPivotTable.d.ts +7 -0
- package/package.json +14 -2
- package/src/App.tsx +18 -4
- package/src/components/leafletRenderer/ChoroplethRenderer.tsx +312 -0
- package/src/components/leafletRenderer/POIRenderer.tsx +189 -0
- package/src/components/leafletRenderer/encodings.ts +194 -0
- package/src/components/leafletRenderer/geoConfigPanel.tsx +197 -0
- package/src/components/leafletRenderer/index.tsx +70 -0
- package/src/components/leafletRenderer/tooltip.tsx +24 -0
- package/src/components/leafletRenderer/utils.ts +52 -0
- package/src/components/pivotTable/index.tsx +171 -67
- package/src/components/pivotTable/inteface.ts +6 -2
- package/src/components/pivotTable/leftTree.tsx +24 -11
- package/src/components/pivotTable/metricTable.tsx +15 -10
- package/src/components/pivotTable/topTree.tsx +50 -17
- package/src/components/pivotTable/utils.ts +70 -11
- package/src/components/visualConfig/index.tsx +17 -1
- package/src/config.ts +27 -16
- package/src/dataSource/table.tsx +7 -11
- package/src/fields/aestheticFields.tsx +4 -0
- package/src/fields/fieldsContext.tsx +3 -0
- package/src/fields/posFields/index.tsx +8 -2
- package/src/global.d.ts +4 -4
- package/src/index.tsx +11 -9
- package/src/interfaces.ts +35 -0
- package/src/locales/en-US.json +27 -2
- package/src/locales/ja-JP.json +27 -2
- package/src/locales/zh-CN.json +27 -2
- package/src/renderer/hooks.ts +1 -1
- package/src/renderer/index.tsx +24 -1
- package/src/renderer/pureRenderer.tsx +27 -13
- package/src/renderer/specRenderer.tsx +46 -30
- package/src/services.ts +32 -23
- package/src/shadow-dom.tsx +7 -0
- package/src/store/commonStore.ts +29 -1
- package/src/store/visualSpecStore.ts +38 -23
- package/src/utils/save.ts +28 -1
- package/src/utils/vegaApiExport.ts +3 -0
- package/src/visualSettings/index.tsx +58 -6
- package/src/workers/buildMetricTable.worker.js +27 -0
- package/src/workers/buildPivotTable.ts +27 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import React, { Fragment, forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react";
|
|
2
|
+
import { CircleMarker, MapContainer, Polygon, Marker, TileLayer, Tooltip } from "react-leaflet";
|
|
3
|
+
import { type Map, divIcon } from "leaflet";
|
|
4
|
+
import type { DeepReadonly, IRow, IViewField, VegaGlobalConfig } from "../../interfaces";
|
|
5
|
+
import type { FeatureCollection, Geometry } from "geojson";
|
|
6
|
+
import { getMeaAggKey } from "../../utils";
|
|
7
|
+
import { useColorScale, useOpacityScale } from "./encodings";
|
|
8
|
+
import { isValidLatLng } from "./POIRenderer";
|
|
9
|
+
import { TooltipContent } from "./tooltip";
|
|
10
|
+
import { useAppRootContext } from "../appRoot";
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
export interface IChoroplethRendererProps {
|
|
14
|
+
name?: string;
|
|
15
|
+
data: IRow[];
|
|
16
|
+
allFields: DeepReadonly<IViewField[]>;
|
|
17
|
+
features: FeatureCollection | undefined;
|
|
18
|
+
geoKey: string;
|
|
19
|
+
defaultAggregated: boolean;
|
|
20
|
+
geoId: DeepReadonly<IViewField>;
|
|
21
|
+
color: DeepReadonly<IViewField> | undefined;
|
|
22
|
+
opacity: DeepReadonly<IViewField> | undefined;
|
|
23
|
+
text: DeepReadonly<IViewField> | undefined;
|
|
24
|
+
details: readonly DeepReadonly<IViewField>[];
|
|
25
|
+
vegaConfig: VegaGlobalConfig;
|
|
26
|
+
scaleIncludeUnmatchedChoropleth: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IChoroplethRendererRef {}
|
|
30
|
+
|
|
31
|
+
const resolveCoords = (featureGeom: Geometry): [lat: number, lng: number][][] => {
|
|
32
|
+
switch (featureGeom.type) {
|
|
33
|
+
case 'Polygon': {
|
|
34
|
+
const coords = featureGeom.coordinates[0];
|
|
35
|
+
return [coords.map<[lat: number, lng: number]>(c => [c[1], c[0]])];
|
|
36
|
+
}
|
|
37
|
+
case 'Point': {
|
|
38
|
+
const coords = featureGeom.coordinates;
|
|
39
|
+
return [[[coords[1], coords[0]]]];
|
|
40
|
+
}
|
|
41
|
+
case 'GeometryCollection': {
|
|
42
|
+
const coords = featureGeom.geometries.map<[lat: number, lng: number][][]>(resolveCoords);
|
|
43
|
+
return coords.flat();
|
|
44
|
+
}
|
|
45
|
+
case 'LineString': {
|
|
46
|
+
const coords = featureGeom.coordinates;
|
|
47
|
+
return [coords.map<[lat: number, lng: number]>(c => [c[1], c[0]])];
|
|
48
|
+
}
|
|
49
|
+
case 'MultiLineString': {
|
|
50
|
+
const coords = featureGeom.coordinates;
|
|
51
|
+
return coords.map<[lat: number, lng: number][]>(c => c.map<[lat: number, lng: number]>(c => [c[1], c[0]]));
|
|
52
|
+
}
|
|
53
|
+
case 'MultiPoint': {
|
|
54
|
+
const coords = featureGeom.coordinates;
|
|
55
|
+
return [coords.map<[lat: number, lng: number]>(c => [c[1], c[0]])];
|
|
56
|
+
}
|
|
57
|
+
case 'MultiPolygon': {
|
|
58
|
+
const coords = featureGeom.coordinates;
|
|
59
|
+
return coords.map<[lat: number, lng: number][]>(c => c[0].map<[lat: number, lng: number]>(c => [c[1], c[0]]));
|
|
60
|
+
}
|
|
61
|
+
default: {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const resolveCenter = (coordinates: [lat: number, lng: number][]): [lng: number, lat: number] => {
|
|
68
|
+
let area = 0;
|
|
69
|
+
let centroid: [lat: number, lng: number] = [0, 0];
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < coordinates.length - 1; i++) {
|
|
72
|
+
let [x1, y1] = coordinates[i];
|
|
73
|
+
let [x2, y2] = coordinates[i + 1];
|
|
74
|
+
|
|
75
|
+
let tempArea = x1 * y2 - x2 * y1;
|
|
76
|
+
area += tempArea;
|
|
77
|
+
|
|
78
|
+
centroid[0] += (x1 + x2) * tempArea;
|
|
79
|
+
centroid[1] += (y1 + y2) * tempArea;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
area /= 2;
|
|
83
|
+
|
|
84
|
+
centroid[0] /= 6 * area;
|
|
85
|
+
centroid[1] /= 6 * area;
|
|
86
|
+
|
|
87
|
+
return centroid;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const ChoroplethRenderer = forwardRef<IChoroplethRendererRef, IChoroplethRendererProps>(function ChoroplethRenderer (props, ref) {
|
|
91
|
+
const { name, data, allFields, features, geoKey, defaultAggregated, geoId, color, opacity, text, details, vegaConfig, scaleIncludeUnmatchedChoropleth } = props;
|
|
92
|
+
|
|
93
|
+
useImperativeHandle(ref, () => ({}));
|
|
94
|
+
|
|
95
|
+
const geoIndices = useMemo(() => {
|
|
96
|
+
if (geoId) {
|
|
97
|
+
return data.map(row => row[geoId.fid]);
|
|
98
|
+
}
|
|
99
|
+
return [];
|
|
100
|
+
}, [geoId, data]);
|
|
101
|
+
|
|
102
|
+
const [indices, geoShapes] = useMemo<[indices: number[], geoShapes: (FeatureCollection['features'][number] | undefined)[]]>(() => {
|
|
103
|
+
if (geoIndices.length && geoKey && features) {
|
|
104
|
+
const indices: number[] = [];
|
|
105
|
+
const shapes = geoIndices.map((id, i) => {
|
|
106
|
+
const feature = id ? features.features.find(f => f.properties?.[geoKey] === id) : undefined;
|
|
107
|
+
if (feature) {
|
|
108
|
+
indices.push(i);
|
|
109
|
+
}
|
|
110
|
+
return feature;
|
|
111
|
+
});
|
|
112
|
+
return [indices, shapes];
|
|
113
|
+
}
|
|
114
|
+
return [[], []];
|
|
115
|
+
}, [geoIndices, features, geoKey]);
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (geoShapes.length > 0) {
|
|
119
|
+
const notMatched = geoShapes.filter(f => !f);
|
|
120
|
+
if (notMatched.length) {
|
|
121
|
+
console.warn(`Failed to render ${notMatched.length.toLocaleString()} items of ${data.length.toLocaleString()} rows due to missing geojson feature.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}, [geoShapes]);
|
|
125
|
+
|
|
126
|
+
const lngLat = useMemo<[lat: number, lng: number][][][]>(() => {
|
|
127
|
+
if (geoShapes.length > 0) {
|
|
128
|
+
return geoShapes.map<[lat: number, lng: number][][]>(feature => {
|
|
129
|
+
if (feature) {
|
|
130
|
+
return resolveCoords(feature.geometry);
|
|
131
|
+
}
|
|
132
|
+
return [];
|
|
133
|
+
}, []);
|
|
134
|
+
}
|
|
135
|
+
return [];
|
|
136
|
+
}, [geoShapes]);
|
|
137
|
+
|
|
138
|
+
const [bounds, center] = useMemo<[bounds: [[n: number, w: number], [s: number, e: number]], center: [lng: number, lat: number]]>(() => {
|
|
139
|
+
const allLngLat = lngLat.flat(2);
|
|
140
|
+
if (allLngLat.length > 0) {
|
|
141
|
+
const [bounds, coords] = allLngLat.reduce<[bounds: [[w: number, n: number], [e: number, s: number]], center: [lat: number, lng: number]]>(([bounds, acc], [lat, lng]) => {
|
|
142
|
+
if (lng < bounds[0][0]) {
|
|
143
|
+
bounds[0][0] = lng;
|
|
144
|
+
}
|
|
145
|
+
if (lng > bounds[1][0]) {
|
|
146
|
+
bounds[1][0] = lng;
|
|
147
|
+
}
|
|
148
|
+
if (lat < bounds[0][1]) {
|
|
149
|
+
bounds[0][1] = lat;
|
|
150
|
+
}
|
|
151
|
+
if (lat > bounds[1][1]) {
|
|
152
|
+
bounds[1][1] = lat;
|
|
153
|
+
}
|
|
154
|
+
return [bounds, [acc[0] + lng, acc[1] + lat]];
|
|
155
|
+
}, [[[-180, -90], [180, 90]], [0, 0]]);
|
|
156
|
+
return [bounds, [coords[0] / lngLat.length, coords[1] / lngLat.length] as [number, number]];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return [[[-180, -90], [180, 90]], [0, 0]];
|
|
160
|
+
}, [lngLat]);
|
|
161
|
+
|
|
162
|
+
const distribution = useMemo(() => {
|
|
163
|
+
if (scaleIncludeUnmatchedChoropleth) {
|
|
164
|
+
return data;
|
|
165
|
+
}
|
|
166
|
+
return indices.map(i => data[i]);
|
|
167
|
+
}, [data, indices, scaleIncludeUnmatchedChoropleth]);
|
|
168
|
+
|
|
169
|
+
const opacityScale = useOpacityScale(distribution, opacity, defaultAggregated);
|
|
170
|
+
const colorScale = useColorScale(distribution, color, defaultAggregated, vegaConfig);
|
|
171
|
+
|
|
172
|
+
const tooltipFields = useMemo(() => {
|
|
173
|
+
return details.concat(
|
|
174
|
+
[color!, opacity!].filter(Boolean)
|
|
175
|
+
).map(f => ({
|
|
176
|
+
...f,
|
|
177
|
+
key: defaultAggregated && f.analyticType === 'measure' && f.aggName ? getMeaAggKey(f.fid, f.aggName) : f.fid,
|
|
178
|
+
}));
|
|
179
|
+
}, [defaultAggregated, details, color, opacity]);
|
|
180
|
+
|
|
181
|
+
const mapRef = useRef<Map>(null);
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
const container = mapRef.current?.getContainer();
|
|
185
|
+
if (container) {
|
|
186
|
+
const ro = new ResizeObserver(() => {
|
|
187
|
+
mapRef.current?.invalidateSize();
|
|
188
|
+
});
|
|
189
|
+
ro.observe(container);
|
|
190
|
+
return () => {
|
|
191
|
+
ro.unobserve(container);
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const appRef = useAppRootContext();
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
const ctx = appRef.current;
|
|
200
|
+
if (ctx) {
|
|
201
|
+
ctx.exportChart = async (mode) => ({
|
|
202
|
+
mode,
|
|
203
|
+
title: name || 'untitled',
|
|
204
|
+
nCols: 0,
|
|
205
|
+
nRows: 0,
|
|
206
|
+
charts: [],
|
|
207
|
+
container: () => mapRef.current?.getContainer() as HTMLDivElement ?? null,
|
|
208
|
+
chartType: 'map'
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
}, []);
|
|
212
|
+
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
mapRef.current?.flyToBounds(bounds);
|
|
215
|
+
}, [`${bounds[0][0]},${bounds[0][1]},${bounds[1][0]},${bounds[1][1]}`]);
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<MapContainer center={center} ref={mapRef} zoom={5} bounds={bounds} style={{ width: '100%', height: '100%', zIndex: 1 }}>
|
|
219
|
+
<TileLayer
|
|
220
|
+
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
221
|
+
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
222
|
+
/>
|
|
223
|
+
{lngLat.length > 0 && data.map((row, i) => {
|
|
224
|
+
const coords = lngLat[i];
|
|
225
|
+
const opacity = opacityScale(row);
|
|
226
|
+
const color = colorScale(row);
|
|
227
|
+
return (
|
|
228
|
+
<Fragment key={`${i}-${opacity}-${color}`}>
|
|
229
|
+
{coords.map((coord, j) => {
|
|
230
|
+
if (coord.length === 0) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
if (coord.length === 1) {
|
|
234
|
+
return (
|
|
235
|
+
<CircleMarker
|
|
236
|
+
key={j}
|
|
237
|
+
center={coord[0]}
|
|
238
|
+
radius={3}
|
|
239
|
+
opacity={0.8}
|
|
240
|
+
fillOpacity={opacity}
|
|
241
|
+
fillColor={color}
|
|
242
|
+
color="#0004"
|
|
243
|
+
weight={1}
|
|
244
|
+
stroke
|
|
245
|
+
fill
|
|
246
|
+
>
|
|
247
|
+
{tooltipFields.length > 0 && (
|
|
248
|
+
<Tooltip>
|
|
249
|
+
<header>{data[i][geoId.fid]}</header>
|
|
250
|
+
{tooltipFields.map((f, j) => (
|
|
251
|
+
<TooltipContent
|
|
252
|
+
key={j}
|
|
253
|
+
allFields={allFields}
|
|
254
|
+
vegaConfig={vegaConfig}
|
|
255
|
+
field={f}
|
|
256
|
+
value={row[f.key]}
|
|
257
|
+
/>
|
|
258
|
+
))}
|
|
259
|
+
</Tooltip>
|
|
260
|
+
)}
|
|
261
|
+
</CircleMarker>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
const center: [lat: number, lng: number] = text && coord.length >= 3 ? resolveCenter(coord) : [NaN, NaN];
|
|
265
|
+
return (
|
|
266
|
+
<Fragment key={j}>
|
|
267
|
+
<Polygon
|
|
268
|
+
positions={coord}
|
|
269
|
+
pathOptions={{
|
|
270
|
+
fillOpacity: opacity * 0.8,
|
|
271
|
+
fillColor: color,
|
|
272
|
+
color: "#0004",
|
|
273
|
+
weight: 1,
|
|
274
|
+
stroke: true,
|
|
275
|
+
fill: true,
|
|
276
|
+
}}
|
|
277
|
+
>
|
|
278
|
+
<Tooltip>
|
|
279
|
+
<header>{data[i][geoId.fid]}</header>
|
|
280
|
+
{tooltipFields.map((f, j) => (
|
|
281
|
+
<TooltipContent
|
|
282
|
+
key={j}
|
|
283
|
+
allFields={allFields}
|
|
284
|
+
vegaConfig={vegaConfig}
|
|
285
|
+
field={f}
|
|
286
|
+
value={row[f.key]}
|
|
287
|
+
/>
|
|
288
|
+
))}
|
|
289
|
+
</Tooltip>
|
|
290
|
+
</Polygon>
|
|
291
|
+
{text && data[i][text.fid] && isValidLatLng(center[0], center[1]) && (
|
|
292
|
+
<Marker
|
|
293
|
+
position={center}
|
|
294
|
+
interactive={false}
|
|
295
|
+
icon={divIcon({
|
|
296
|
+
className: '!bg-transparent !border-none',
|
|
297
|
+
html: `<div style="font-size: 11px; transform: translate(-50%, -50%); opacity: 0.8;">${data[i][text.fid]}</div>`,
|
|
298
|
+
})}
|
|
299
|
+
/>
|
|
300
|
+
)}
|
|
301
|
+
</Fragment>
|
|
302
|
+
);
|
|
303
|
+
})}
|
|
304
|
+
</Fragment>
|
|
305
|
+
);
|
|
306
|
+
})}
|
|
307
|
+
</MapContainer>
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
export default ChoroplethRenderer;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import React, { forwardRef, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { MapContainer, TileLayer, Tooltip, CircleMarker } from "react-leaflet";
|
|
3
|
+
import type { Map } from "leaflet";
|
|
4
|
+
import type { DeepReadonly, IRow, IViewField, VegaGlobalConfig } from "../../interfaces";
|
|
5
|
+
import { getMeaAggKey } from "../../utils";
|
|
6
|
+
import { useColorScale, useOpacityScale, useSizeScale } from "./encodings";
|
|
7
|
+
import { TooltipContent } from "./tooltip";
|
|
8
|
+
import { useAppRootContext } from "../appRoot";
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export interface IPOIRendererProps {
|
|
12
|
+
name?: string;
|
|
13
|
+
data: IRow[];
|
|
14
|
+
allFields: DeepReadonly<IViewField[]>;
|
|
15
|
+
defaultAggregated: boolean;
|
|
16
|
+
latitude: DeepReadonly<IViewField> | undefined;
|
|
17
|
+
longitude: DeepReadonly<IViewField> | undefined;
|
|
18
|
+
color: DeepReadonly<IViewField> | undefined;
|
|
19
|
+
opacity: DeepReadonly<IViewField> | undefined;
|
|
20
|
+
size: DeepReadonly<IViewField> | undefined;
|
|
21
|
+
details: readonly DeepReadonly<IViewField>[];
|
|
22
|
+
vegaConfig: VegaGlobalConfig;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface IPOIRendererRef {}
|
|
26
|
+
|
|
27
|
+
export const isValidLatLng = (latRaw: unknown, lngRaw: unknown) => {
|
|
28
|
+
const lat = Number(latRaw);
|
|
29
|
+
const lng = Number(lngRaw);
|
|
30
|
+
return lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const formatCoerceLatLng = (latRaw: unknown, lngRaw: unknown) => {
|
|
34
|
+
return `${
|
|
35
|
+
typeof latRaw === 'number' ? latRaw : JSON.stringify(latRaw)
|
|
36
|
+
}, ${
|
|
37
|
+
typeof lngRaw === 'number' ? lngRaw : JSON.stringify(lngRaw)
|
|
38
|
+
}`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const debugMaxLen = 20;
|
|
42
|
+
|
|
43
|
+
const POIRenderer = forwardRef<IPOIRendererRef, IPOIRendererProps>(function POIRenderer (props, ref) {
|
|
44
|
+
const { name, data, allFields, latitude, longitude, color, opacity, size, details, defaultAggregated, vegaConfig } = props;
|
|
45
|
+
|
|
46
|
+
const lngLat = useMemo<[lat: number, lng: number][]>(() => {
|
|
47
|
+
if (longitude && latitude) {
|
|
48
|
+
return data.map<[lat: number, lng: number]>(row => [Number(row[latitude.fid]), Number(row[longitude.fid])]).filter(v => isValidLatLng(v[0], v[1]));
|
|
49
|
+
}
|
|
50
|
+
return [];
|
|
51
|
+
}, [longitude, latitude, data]);
|
|
52
|
+
|
|
53
|
+
const [bounds, center] = useMemo<[bounds: [[n: number, w: number], [s: number, e: number]], center: [lng: number, lat: number]]>(() => {
|
|
54
|
+
if (lngLat.length > 0) {
|
|
55
|
+
const [bounds, coords] = lngLat.reduce<[bounds: [[w: number, n: number], [e: number, s: number]], center: [lat: number, lng: number]]>(([bounds, acc], [lat, lng]) => {
|
|
56
|
+
if (lng < bounds[0][0]) {
|
|
57
|
+
bounds[0][0] = lng;
|
|
58
|
+
}
|
|
59
|
+
if (lng > bounds[1][0]) {
|
|
60
|
+
bounds[1][0] = lng;
|
|
61
|
+
}
|
|
62
|
+
if (lat < bounds[0][1]) {
|
|
63
|
+
bounds[0][1] = lat;
|
|
64
|
+
}
|
|
65
|
+
if (lat > bounds[1][1]) {
|
|
66
|
+
bounds[1][1] = lat;
|
|
67
|
+
}
|
|
68
|
+
return [bounds, [acc[0] + lng, acc[1] + lat]];
|
|
69
|
+
}, [[[-180, -90], [180, 90]], [0, 0]]);
|
|
70
|
+
return [bounds, [coords[0] / lngLat.length, coords[1] / lngLat.length] as [number, number]];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [[[-180, -90], [180, 90]], [0, 0]];
|
|
74
|
+
}, [lngLat]);
|
|
75
|
+
|
|
76
|
+
const failedLatLngListRef = useRef<[index: number, lng: unknown, lat: unknown][]>([]);
|
|
77
|
+
failedLatLngListRef.current = [];
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (failedLatLngListRef.current.length > 0) {
|
|
81
|
+
console.warn(`Failed to render ${failedLatLngListRef.current.length.toLocaleString()} markers of ${data.length.toLocaleString()} rows due to invalid lat/lng.\n--------\n${
|
|
82
|
+
`${failedLatLngListRef.current.slice(0, debugMaxLen).map(([idx, lng, lat]) =>
|
|
83
|
+
`[${idx + 1}] ${formatCoerceLatLng(lat, lng)}`
|
|
84
|
+
).join('\n')}`
|
|
85
|
+
+ (failedLatLngListRef.current.length > debugMaxLen ? `\n\t... and ${(failedLatLngListRef.current.length - debugMaxLen).toLocaleString()} more` : '')
|
|
86
|
+
}\n`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const mapRef = useRef<Map>(null);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const container = mapRef.current?.getContainer();
|
|
94
|
+
if (container) {
|
|
95
|
+
const ro = new ResizeObserver(() => {
|
|
96
|
+
mapRef.current?.invalidateSize();
|
|
97
|
+
});
|
|
98
|
+
ro.observe(container);
|
|
99
|
+
return () => {
|
|
100
|
+
ro.unobserve(container);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const appRef = useAppRootContext();
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const ctx = appRef.current;
|
|
109
|
+
if (ctx) {
|
|
110
|
+
ctx.exportChart = async (mode) => ({
|
|
111
|
+
mode,
|
|
112
|
+
title: name || 'untitled',
|
|
113
|
+
nCols: 0,
|
|
114
|
+
nRows: 0,
|
|
115
|
+
charts: [],
|
|
116
|
+
container: () => mapRef.current?.getContainer() as HTMLDivElement ?? null,
|
|
117
|
+
chartType: 'map',
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
mapRef.current?.flyToBounds(bounds);
|
|
124
|
+
}, [`${bounds[0][0]},${bounds[0][1]},${bounds[1][0]},${bounds[1][1]}`]);
|
|
125
|
+
|
|
126
|
+
const sizeScale = useSizeScale(data, size, defaultAggregated);
|
|
127
|
+
const opacityScale = useOpacityScale(data, opacity, defaultAggregated);
|
|
128
|
+
const colorScale = useColorScale(data, color, defaultAggregated, vegaConfig);
|
|
129
|
+
|
|
130
|
+
const tooltipFields = useMemo(() => {
|
|
131
|
+
return details.concat(
|
|
132
|
+
[size!, color!, opacity!].filter(Boolean)
|
|
133
|
+
).map(f => ({
|
|
134
|
+
...f,
|
|
135
|
+
key: defaultAggregated && f.analyticType === 'measure' && f.aggName ? getMeaAggKey(f.fid, f.aggName) : f.fid,
|
|
136
|
+
}));
|
|
137
|
+
}, [defaultAggregated, details, size, color, opacity]);
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<MapContainer center={center} ref={mapRef} zoom={5} bounds={bounds} style={{ width: '100%', height: '100%', zIndex: 1 }}>
|
|
141
|
+
<TileLayer
|
|
142
|
+
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
143
|
+
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
144
|
+
/>
|
|
145
|
+
{Boolean(latitude && longitude) && data.map((row, i) => {
|
|
146
|
+
const lat = row[latitude!.fid];
|
|
147
|
+
const lng = row[longitude!.fid];
|
|
148
|
+
if (!isValidLatLng(lat, lng)) {
|
|
149
|
+
failedLatLngListRef.current.push([i, lat, lng]);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const radius = sizeScale(row);
|
|
153
|
+
const opacity = opacityScale(row);
|
|
154
|
+
const color = colorScale(row);
|
|
155
|
+
return (
|
|
156
|
+
<CircleMarker
|
|
157
|
+
key={`${i}-${radius}-${opacity}-${color}`}
|
|
158
|
+
center={[Number(lat), Number(lng)]}
|
|
159
|
+
radius={radius}
|
|
160
|
+
opacity={0.8}
|
|
161
|
+
fillOpacity={opacity}
|
|
162
|
+
fillColor={color}
|
|
163
|
+
color="#0004"
|
|
164
|
+
weight={1}
|
|
165
|
+
stroke
|
|
166
|
+
fill
|
|
167
|
+
>
|
|
168
|
+
{tooltipFields.length > 0 && (
|
|
169
|
+
<Tooltip>
|
|
170
|
+
{tooltipFields.map((f, j) => (
|
|
171
|
+
<TooltipContent
|
|
172
|
+
key={j}
|
|
173
|
+
allFields={allFields}
|
|
174
|
+
vegaConfig={vegaConfig}
|
|
175
|
+
field={f}
|
|
176
|
+
value={row[f.key]}
|
|
177
|
+
/>
|
|
178
|
+
))}
|
|
179
|
+
</Tooltip>
|
|
180
|
+
)}
|
|
181
|
+
</CircleMarker>
|
|
182
|
+
);
|
|
183
|
+
})}
|
|
184
|
+
</MapContainer>
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
export default POIRenderer;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import { scaleLinear, scaleOrdinal } from "d3-scale";
|
|
3
|
+
import type { IRow, IViewField, VegaGlobalConfig } from "../../interfaces";
|
|
4
|
+
import { getMeaAggKey } from "../../utils";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
export interface Scale<T> {
|
|
8
|
+
(record: IRow): T;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULT_COLOR = "#5B8FF9";
|
|
12
|
+
const DEFAULT_COLOR_STEP_1 = "#EBCCFF";
|
|
13
|
+
const DEFAULT_COLOR_STEP_2 = "#0D1090";
|
|
14
|
+
const DEFAULT_SCHEME_CATEGORY = [
|
|
15
|
+
"#5B8FF9",
|
|
16
|
+
"#61DDAA",
|
|
17
|
+
"#65789B",
|
|
18
|
+
"#F6BD16",
|
|
19
|
+
"#7262FD",
|
|
20
|
+
"#78D3F8",
|
|
21
|
+
"#9661BC",
|
|
22
|
+
"#F6903D",
|
|
23
|
+
"#008685",
|
|
24
|
+
"#F08BB4",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const useColorScale = (data: IRow[], field: IViewField | null | undefined, defaultAggregate: boolean, vegaConfig: VegaGlobalConfig): Scale<string> => {
|
|
28
|
+
const color = (vegaConfig as any).circle?.fill || DEFAULT_COLOR;
|
|
29
|
+
const fixedScale = useCallback(function ColorScale (row: IRow) {
|
|
30
|
+
return color;
|
|
31
|
+
}, [color]);
|
|
32
|
+
const colorRange = useMemo(() => {
|
|
33
|
+
if ('scale' in vegaConfig && typeof vegaConfig.scale === 'object' && 'continuous' in vegaConfig.scale) {
|
|
34
|
+
if (Array.isArray((vegaConfig.scale?.continuous as any).range)) {
|
|
35
|
+
return ((vegaConfig.scale?.continuous as any).range as string[]).slice(0, 2);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return [DEFAULT_COLOR_STEP_1, DEFAULT_COLOR_STEP_2];
|
|
39
|
+
}, [vegaConfig]);
|
|
40
|
+
const schemeCategory = useMemo(() => {
|
|
41
|
+
if (Array.isArray(vegaConfig.range?.category)) {
|
|
42
|
+
return vegaConfig.range!.category as string[];
|
|
43
|
+
}
|
|
44
|
+
return DEFAULT_SCHEME_CATEGORY;
|
|
45
|
+
}, [vegaConfig]);
|
|
46
|
+
const key = useMemo(() => {
|
|
47
|
+
if (!field) {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
if (defaultAggregate && field.aggName && field.analyticType === 'measure') {
|
|
51
|
+
return getMeaAggKey(field.fid, field.aggName);
|
|
52
|
+
}
|
|
53
|
+
return field.fid;
|
|
54
|
+
}, [field, defaultAggregate]);
|
|
55
|
+
const domain = useMemo<[number, number]>(() => {
|
|
56
|
+
if (!field || field.semanticType === 'nominal') {
|
|
57
|
+
return [0, 0];
|
|
58
|
+
}
|
|
59
|
+
return data.reduce((dom: [number, number], { [key]: cur }) => {
|
|
60
|
+
if (cur < dom[0]) {
|
|
61
|
+
dom[0] = cur;
|
|
62
|
+
}
|
|
63
|
+
if (cur > dom[1]) {
|
|
64
|
+
dom[1] = cur;
|
|
65
|
+
}
|
|
66
|
+
return dom;
|
|
67
|
+
}, [Infinity, -Infinity]);
|
|
68
|
+
}, [data, field, key]);
|
|
69
|
+
const distributions = useMemo(() => {
|
|
70
|
+
if (!field || field.semanticType !== 'nominal') {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
return [...data.reduce((set: Set<string>, row) => {
|
|
74
|
+
set.add(row[key]);
|
|
75
|
+
return set;
|
|
76
|
+
}, new Set<string>())];
|
|
77
|
+
}, [data, field, key]);
|
|
78
|
+
const continuousScale = useMemo(() => {
|
|
79
|
+
const scale = scaleLinear<string, string>().domain(domain).range(colorRange);
|
|
80
|
+
return function ColorScale (row: IRow) {
|
|
81
|
+
return scale(Number(row[key]));
|
|
82
|
+
};
|
|
83
|
+
}, [domain, key, colorRange]);
|
|
84
|
+
const discreteScale = useMemo(() => {
|
|
85
|
+
const scale = scaleOrdinal<string, string>().domain(distributions).range(schemeCategory);
|
|
86
|
+
return function ColorScale (row: IRow) {
|
|
87
|
+
return scale(row[key]);
|
|
88
|
+
};
|
|
89
|
+
}, [distributions, schemeCategory]);
|
|
90
|
+
|
|
91
|
+
if (!field) {
|
|
92
|
+
return fixedScale;
|
|
93
|
+
}
|
|
94
|
+
if (field.semanticType === 'quantitative' || field.semanticType === 'temporal') {
|
|
95
|
+
// continuous
|
|
96
|
+
return continuousScale;
|
|
97
|
+
}
|
|
98
|
+
return discreteScale;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const MIN_SIZE = 2;
|
|
102
|
+
const MAX_SIZE = 10;
|
|
103
|
+
const DEFAULT_SIZE = 3;
|
|
104
|
+
|
|
105
|
+
export const useSizeScale = (data: IRow[], field: IViewField | null | undefined, defaultAggregate: boolean): Scale<number> => {
|
|
106
|
+
const key = useMemo(() => {
|
|
107
|
+
if (!field) {
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
if (defaultAggregate && field.aggName && field.analyticType === 'measure') {
|
|
111
|
+
return getMeaAggKey(field.fid, field.aggName);
|
|
112
|
+
}
|
|
113
|
+
return field.fid;
|
|
114
|
+
}, [field, defaultAggregate]);
|
|
115
|
+
|
|
116
|
+
const [domainMin, domainMax] = useMemo(() => {
|
|
117
|
+
if (!key) {
|
|
118
|
+
return [0, 0];
|
|
119
|
+
}
|
|
120
|
+
const values = data.map((row) => Number(row[key])).filter((val) => !isNaN(val));
|
|
121
|
+
if (values.length === 0) {
|
|
122
|
+
return [0, 0];
|
|
123
|
+
}
|
|
124
|
+
return values.slice(1).reduce<[number, number]>((acc, val) => {
|
|
125
|
+
if (val < acc[0]) {
|
|
126
|
+
acc[0] = val;
|
|
127
|
+
}
|
|
128
|
+
if (val > acc[1]) {
|
|
129
|
+
acc[1] = val;
|
|
130
|
+
}
|
|
131
|
+
return acc;
|
|
132
|
+
}, [values[0], values[0]]);
|
|
133
|
+
}, [key, data]);
|
|
134
|
+
|
|
135
|
+
return useCallback(function SizeScale (record: IRow): number {
|
|
136
|
+
if (!key) {
|
|
137
|
+
return DEFAULT_SIZE;
|
|
138
|
+
}
|
|
139
|
+
const val = Number(record[key]);
|
|
140
|
+
if (isNaN(val)) {
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
const size = (val - domainMin) / (domainMax - domainMin);
|
|
144
|
+
return MIN_SIZE + Math.sqrt(size) * (MAX_SIZE - MIN_SIZE);
|
|
145
|
+
}, [key, domainMin, domainMax, defaultAggregate]);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
const MIN_OPACITY = 0.33;
|
|
150
|
+
const MAX_OPACITY = 1.0;
|
|
151
|
+
const DEFAULT_OPACITY = 1;
|
|
152
|
+
|
|
153
|
+
export const useOpacityScale = (data: IRow[], field: IViewField | null | undefined, defaultAggregate: boolean): Scale<number> => {
|
|
154
|
+
const key = useMemo(() => {
|
|
155
|
+
if (!field) {
|
|
156
|
+
return '';
|
|
157
|
+
}
|
|
158
|
+
if (defaultAggregate && field.aggName && field.analyticType === 'measure') {
|
|
159
|
+
return getMeaAggKey(field.fid, field.aggName);
|
|
160
|
+
}
|
|
161
|
+
return field.fid;
|
|
162
|
+
}, [field, defaultAggregate]);
|
|
163
|
+
|
|
164
|
+
const [domainMin, domainMax] = useMemo(() => {
|
|
165
|
+
if (!key) {
|
|
166
|
+
return [0, 0];
|
|
167
|
+
}
|
|
168
|
+
const values = data.map((row) => Number(row[key])).filter((val) => !isNaN(val));
|
|
169
|
+
if (values.length === 0) {
|
|
170
|
+
return [0, 0];
|
|
171
|
+
}
|
|
172
|
+
return values.slice(1).reduce<[number, number]>((acc, val) => {
|
|
173
|
+
if (val < acc[0]) {
|
|
174
|
+
acc[0] = val;
|
|
175
|
+
}
|
|
176
|
+
if (val > acc[1]) {
|
|
177
|
+
acc[1] = val;
|
|
178
|
+
}
|
|
179
|
+
return acc;
|
|
180
|
+
}, [values[0], values[0]]);
|
|
181
|
+
}, [key, data]);
|
|
182
|
+
|
|
183
|
+
return useCallback(function OpacityScale (record: IRow): number {
|
|
184
|
+
if (!key) {
|
|
185
|
+
return DEFAULT_OPACITY;
|
|
186
|
+
}
|
|
187
|
+
const val = Number(record[key]);
|
|
188
|
+
if (isNaN(val)) {
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
const size = (val - domainMin) / (domainMax - domainMin);
|
|
192
|
+
return MIN_OPACITY + size * (MAX_OPACITY - MIN_OPACITY);
|
|
193
|
+
}, [key, domainMin, domainMax, defaultAggregate]);
|
|
194
|
+
};
|