@ldforge/geo 0.0.0-stage → 0.1.2
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/LICENSE +190 -0
- package/README.md +46 -2
- package/dist/index.d.ts +80 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +309 -0
- package/dist/index.js.map +1 -0
- package/dist/model.d.ts +32 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +117 -0
- package/dist/model.js.map +1 -0
- package/dist/modes.d.ts +41 -0
- package/dist/modes.d.ts.map +1 -0
- package/dist/modes.js +117 -0
- package/dist/modes.js.map +1 -0
- package/package.json +37 -4
- package/src/index.ts +421 -0
- package/src/model.ts +137 -0
- package/src/modes.ts +165 -0
package/src/modes.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { GeoFeature, Position } from './model.js';
|
|
2
|
+
import type { GeoLayerHandle, GeoMapHandle } from './index.js';
|
|
3
|
+
|
|
4
|
+
export type GeoMode = 'normal' | 'grouped' | 'heatmap';
|
|
5
|
+
export interface ClusterIndex {
|
|
6
|
+
load(features: GeoFeature[]): unknown;
|
|
7
|
+
getClusters(
|
|
8
|
+
bounds: [number, number, number, number],
|
|
9
|
+
zoom: number,
|
|
10
|
+
): {
|
|
11
|
+
geometry: { type: 'Point'; coordinates: Position };
|
|
12
|
+
properties: {
|
|
13
|
+
cluster?: boolean;
|
|
14
|
+
cluster_id?: number;
|
|
15
|
+
point_count?: number;
|
|
16
|
+
label?: string;
|
|
17
|
+
};
|
|
18
|
+
}[];
|
|
19
|
+
getClusterExpansionZoom(id: number): number;
|
|
20
|
+
}
|
|
21
|
+
export interface ClusterConstructor {
|
|
22
|
+
new (options: { radius: number; maxZoom: number }): ClusterIndex;
|
|
23
|
+
}
|
|
24
|
+
export interface HeatHandle {
|
|
25
|
+
data(points: [number, number, number][]): HeatHandle;
|
|
26
|
+
max(value: number): HeatHandle;
|
|
27
|
+
radius(value: number, blur: number): HeatHandle;
|
|
28
|
+
resize(): void;
|
|
29
|
+
draw(opacity: number): HeatHandle;
|
|
30
|
+
}
|
|
31
|
+
export type HeatFactory = (canvas: HTMLCanvasElement) => HeatHandle;
|
|
32
|
+
export type ModeRenderer = (
|
|
33
|
+
map: GeoMapHandle,
|
|
34
|
+
frame: HTMLElement,
|
|
35
|
+
draw: (features: GeoFeature[]) => GeoLayerHandle,
|
|
36
|
+
) => () => void;
|
|
37
|
+
|
|
38
|
+
export function splitPoints(features: GeoFeature[]): {
|
|
39
|
+
points: GeoFeature[];
|
|
40
|
+
shapes: GeoFeature[];
|
|
41
|
+
} {
|
|
42
|
+
const points: GeoFeature[] = [];
|
|
43
|
+
const shapes: GeoFeature[] = [];
|
|
44
|
+
for (const feature of features) {
|
|
45
|
+
if (feature.geometry.type === 'Point') points.push(feature);
|
|
46
|
+
else if (feature.geometry.type === 'MultiPoint')
|
|
47
|
+
for (const coordinates of feature.geometry.coordinates)
|
|
48
|
+
points.push({ ...feature, geometry: { type: 'Point', coordinates } });
|
|
49
|
+
else shapes.push(feature);
|
|
50
|
+
}
|
|
51
|
+
return { points, shapes };
|
|
52
|
+
}
|
|
53
|
+
function defaultExport(module: unknown): unknown {
|
|
54
|
+
return (module as { default?: unknown } | null)?.default ?? module;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Load before mutating the live map, so stale mode requests can be discarded. */
|
|
58
|
+
export async function prepareMode(
|
|
59
|
+
mode: GeoMode,
|
|
60
|
+
features: GeoFeature[],
|
|
61
|
+
loadCluster: () => Promise<unknown>,
|
|
62
|
+
loadHeat: () => Promise<unknown>,
|
|
63
|
+
): Promise<ModeRenderer> {
|
|
64
|
+
const { points, shapes } = splitPoints(features);
|
|
65
|
+
if (mode === 'normal' || !points.length)
|
|
66
|
+
return (_map, _frame, draw) => {
|
|
67
|
+
const layer = draw(features);
|
|
68
|
+
return () => layer.remove();
|
|
69
|
+
};
|
|
70
|
+
if (mode === 'grouped') {
|
|
71
|
+
const Constructor = defaultExport(await loadCluster());
|
|
72
|
+
if (typeof Constructor !== 'function')
|
|
73
|
+
throw new Error('Expected Supercluster module.');
|
|
74
|
+
const index = new (Constructor as ClusterConstructor)({
|
|
75
|
+
radius: 50,
|
|
76
|
+
maxZoom: 18,
|
|
77
|
+
});
|
|
78
|
+
index.load(points);
|
|
79
|
+
return (map, _frame, draw) => {
|
|
80
|
+
const shapesLayer = draw(shapes);
|
|
81
|
+
let clusters: GeoLayerHandle | undefined;
|
|
82
|
+
const refresh = () => {
|
|
83
|
+
clusters?.remove();
|
|
84
|
+
clusters = draw(
|
|
85
|
+
index
|
|
86
|
+
.getClusters([-180, -90, 180, 90], Math.floor(map.getZoom()))
|
|
87
|
+
.map((feature) => ({
|
|
88
|
+
type: 'Feature',
|
|
89
|
+
geometry: feature.geometry,
|
|
90
|
+
properties: feature.properties.cluster
|
|
91
|
+
? {
|
|
92
|
+
label: String(feature.properties.point_count),
|
|
93
|
+
count: feature.properties.point_count ?? 1,
|
|
94
|
+
expansionZoom: index.getClusterExpansionZoom(
|
|
95
|
+
feature.properties.cluster_id ?? 0,
|
|
96
|
+
),
|
|
97
|
+
}
|
|
98
|
+
: { label: feature.properties.label ?? '' },
|
|
99
|
+
})),
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
refresh();
|
|
104
|
+
map.on('zoomend', refresh);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
shapesLayer.remove();
|
|
107
|
+
clusters?.remove();
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
return () => {
|
|
111
|
+
map.off('zoomend', refresh);
|
|
112
|
+
shapesLayer.remove();
|
|
113
|
+
clusters?.remove();
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const factory = defaultExport(await loadHeat());
|
|
118
|
+
if (typeof factory !== 'function')
|
|
119
|
+
throw new Error('Expected simpleheat module.');
|
|
120
|
+
return (map, frame, draw) => {
|
|
121
|
+
const shapesLayer = draw(shapes);
|
|
122
|
+
const canvas = frame.ownerDocument.createElement('canvas');
|
|
123
|
+
canvas.className = 'ldforge-geo-heatmap';
|
|
124
|
+
Object.assign(canvas.style, {
|
|
125
|
+
position: 'absolute',
|
|
126
|
+
inset: '0',
|
|
127
|
+
pointerEvents: 'none',
|
|
128
|
+
zIndex: '450',
|
|
129
|
+
});
|
|
130
|
+
canvas.setAttribute('aria-hidden', 'true');
|
|
131
|
+
frame.append(canvas);
|
|
132
|
+
let heat: HeatHandle;
|
|
133
|
+
const refresh = () => {
|
|
134
|
+
const size = map.getSize();
|
|
135
|
+
canvas.width = size.x;
|
|
136
|
+
canvas.height = size.y;
|
|
137
|
+
const data: [number, number, number][] = points
|
|
138
|
+
.map((feature): [number, number, number] => {
|
|
139
|
+
const [lng, lat] = feature.geometry.coordinates as Position;
|
|
140
|
+
const position = map.latLngToContainerPoint([lat, lng]);
|
|
141
|
+
return [position.x, position.y, 1];
|
|
142
|
+
})
|
|
143
|
+
.filter(
|
|
144
|
+
([x, y]) =>
|
|
145
|
+
x >= -50 && y >= -50 && x <= size.x + 50 && y <= size.y + 50,
|
|
146
|
+
);
|
|
147
|
+
heat.resize();
|
|
148
|
+
heat.data(data).max(5).radius(20, 15).draw(0.15);
|
|
149
|
+
};
|
|
150
|
+
try {
|
|
151
|
+
heat = (factory as HeatFactory)(canvas);
|
|
152
|
+
refresh();
|
|
153
|
+
map.on('move zoom resize', refresh);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
canvas.remove();
|
|
156
|
+
shapesLayer.remove();
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
return () => {
|
|
160
|
+
map.off('move zoom resize', refresh);
|
|
161
|
+
canvas.remove();
|
|
162
|
+
shapesLayer.remove();
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
}
|