All examplesanalysisline-of-sight
§ examples · analysis

Line of Sight

LosTool + BuildingObstacleProvider with elevation profile chart, 2D and 3D modes.

slugline-of-sight
source218 lines
statuslive
tsexamples-src/line-of-sight.ts
1/**
2 * Line of Sight — `LosTool` placed on the ToolManager and driven by the
3 * `LOSWidget`. Building obstacles come from a MVT vector tile layer
4 * (OpenFreeMap buildings), consumed via `BuildingObstacleProvider`.
5 * The widget handles observer / target placement and height sliders;
6 * LOS result is rendered as a line shaded visible vs blocked.
7 */
8 
9import { MapView } from "mapgpu";
10import { GraphicsLayer, RasterTileLayer, VectorTileLayer } from "mapgpu/layers";
11import { RenderEngine } from "mapgpu/render";
12import { ClassBreaksRenderer } from "mapgpu/core";
13import { BuildingObstacleProvider, LosAnalysis } from "mapgpu/analysis";
14import { LosTool } from "mapgpu/tools";
15import { LOSWidget } from "mapgpu/widgets";
16 
17import type { RunResultObject } from "@/components/examples/ExampleCanvas";
18 
19// Minimal wasm mock: the LOS pipeline normally calls into a wasm module
20// for sampling. The methods below supply the two LOS entry points
21// (`generateLosSegments`, `computeLos`) in plain TS so the example can
22// run without loading the wasm blob.
23const wasm = {
24 async init() {},
25 reprojectPoints(c: Float64Array) { return c; },
26 triangulate(v: Float64Array) {
27 return { vertices: v, indices: new Uint32Array(0) };
28 },
29 tessellateLines(p: Float64Array) { return p; },
30 clusterPoints() {
31 return {
32 centroids: new Float64Array(0),
33 counts: new Uint32Array(0),
34 assignments: new Int32Array(0),
35 };
36 },
37 buildSpatialIndex() { return { _handle: 0 }; },
38 querySpatialIndex() { return { ids: new Uint32Array(0) }; },
39 parseGeojson() {
40 return {
41 geometryType: 0,
42 positions: new Float64Array(0),
43 offsets: new Uint32Array(0),
44 featureIds: new Uint32Array(0),
45 featureCount: 0,
46 };
47 },
48 parseMvt() {
49 return {
50 geometryType: 0,
51 positions: new Float64Array(0),
52 offsets: new Uint32Array(0),
53 featureIds: new Uint32Array(0),
54 featureCount: 0,
55 };
56 },
57 geodeticToEcef(c: Float64Array) { return c; },
58 encodeEcefDouble() { return new Float32Array(0); },
59 destroy() {},
60 generateLosSegments(observer: Float64Array, target: Float64Array, sampleCount: number) {
61 const result = new Float64Array(sampleCount * 3);
62 for (let i = 0; i < sampleCount; i++) {
63 const t = sampleCount > 1 ? i / (sampleCount - 1) : 0;
64 result[i * 3] = observer[0]! + t * (target[0]! - observer[0]!);
65 result[i * 3 + 1] = observer[1]! + t * (target[1]! - observer[1]!);
66 result[i * 3 + 2] = observer[2]! + t * (target[2]! - observer[2]!);
67 }
68 return result;
69 },
70 computeLos(
71 segments: Float64Array,
72 elevations: Float64Array,
73 observerOffset: number,
74 targetOffset: number,
75 ) {
76 const count = segments.length / 3;
77 // Simple visibility check — consider line blocked if any sample's
78 // elevation exceeds the straight-line interpolated ray.
79 let visible = true;
80 let blocker: Float64Array | null = null;
81 const obsZ = (segments[2] ?? 0) + observerOffset;
82 const tgtZ = (segments[segments.length - 1] ?? 0) + targetOffset;
83 const profile = new Float64Array(count * 2);
84 for (let i = 0; i < count; i++) {
85 const t = count > 1 ? i / (count - 1) : 0;
86 const rayZ = obsZ + t * (tgtZ - obsZ);
87 const terrainZ = elevations[i] ?? 0;
88 profile[i * 2] = t;
89 profile[i * 2 + 1] = terrainZ;
90 if (terrainZ > rayZ && visible) {
91 visible = false;
92 blocker = new Float64Array([
93 segments[i * 3]!,
94 segments[i * 3 + 1]!,
95 terrainZ,
96 ]);
97 }
98 }
99 return { visible, blockingPoint: blocker, profile };
100 },
101};
102 
103export async function run(container: HTMLElement): Promise<RunResultObject> {
104 const view = new MapView({
105 container,
106 renderEngine: new RenderEngine(),
107 mode: "2d",
108 center: [28.9784, 41.0082], // Istanbul (dense buildings)
109 zoom: 15,
110 minZoom: 4,
111 maxZoom: 19,
112 backgroundColor: "transparent",
113 });
114 
115 view.map.add(
116 new RasterTileLayer({
117 id: "osm",
118 urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
119 maxZoom: 19,
120 attribution: "© OpenStreetMap contributors",
121 }),
122 );
123 
124 // Class breaks so buildings extrude visibly and tint by height.
125 const HEIGHT_COLORS: Array<[number, number, number]> = [
126 [150, 170, 200], [180, 190, 210], [200, 200, 210],
127 [220, 200, 200], [230, 180, 170], [230, 140, 120],
128 [220, 100, 80], [190, 60, 60],
129 ];
130 const HEIGHT_BREAKS = [0, 6, 12, 20, 35, 60, 100, 200];
131 
132 const buildingRenderer = new ClassBreaksRenderer({
133 field: "render_height",
134 defaultSymbol: {
135 type: "fill-extrusion",
136 color: [...HEIGHT_COLORS[2]!, 225] as [number, number, number, number],
137 heightField: "render_height",
138 minHeightField: "render_min_height",
139 ambient: 0.4,
140 shininess: 24,
141 specularStrength: 0.15,
142 },
143 breaks: HEIGHT_COLORS.map((c, i) => ({
144 min: HEIGHT_BREAKS[i]!,
145 max: HEIGHT_BREAKS[i + 1] ?? Infinity,
146 symbol: {
147 type: "fill-extrusion",
148 color: [...c, 225] as [number, number, number, number],
149 heightField: "render_height",
150 minHeightField: "render_min_height",
151 ambient: 0.4,
152 shininess: 24,
153 specularStrength: 0.15,
154 },
155 })),
156 });
157 
158 const buildings = new VectorTileLayer({
159 id: "buildings",
160 url: "https://tiles.openfreemap.org/planet/20260311_001001_pt/{z}/{x}/{y}.pbf",
161 sourceLayer: "building",
162 minZoom: 13,
163 maxZoom: 14,
164 renderer: buildingRenderer,
165 });
166 view.map.add(buildings);
167 
168 const losAnalysis = new LosAnalysis(wasm as never);
169 losAnalysis.setElevationProvider(
170 new BuildingObstacleProvider({
171 getFeatures: () => buildings.getFeatures(),
172 heightField: "render_height",
173 minHeightField: "render_min_height",
174 }),
175 );
176 
177 const preview = new GraphicsLayer({ id: "los-preview" });
178 view.map.add(preview);
179 view.toolManager.setPreviewLayer(preview);
180 
181 const losTool = new LosTool({ analysis: losAnalysis, sampleCount: 512 });
182 view.toolManager.registerTool(losTool);
183 
184 const widget = new LOSWidget({ id: "los-widget", position: "top-right" });
185 widget.mount(container);
186 widget.bind(view);
187 widget.bindLosTool(losTool, view.toolManager as never);
188 
189 // The widget's "Pick Points" button fires registered onPick handlers.
190 // Activating the LosTool here turns the next map clicks into observer
191 // and target placements (LosTool handles the canvas pointer flow and
192 // emits `los-update` back to the widget via ToolManager).
193 widget.onPick(() => {
194 view.toolManager.activateTool(losTool.id);
195 });
196 
197 widget.onRunLos(async (params) => {
198 const result = await losAnalysis.runLos({
199 observer: params.observer,
200 target: params.target,
201 observerOffset: params.observerOffset,
202 targetOffset: params.targetOffset,
203 sampleCount: 512,
204 });
205 widget.setResult(result);
206 });
207 
208 await view.when();
209 
210 return {
211 dispose: () => {
212 widget.destroy();
213 view.destroy();
214 },
215 controls: [],
216 };
217}