Skip to content

Commit 25353c3

Browse files
authored
fix: crisp territory boundaries at high zoom + stop power plant flashing (#15)
1 parent c2c6ca0 commit 25353c3

File tree

5 files changed

+26
-200
lines changed

5 files changed

+26
-200
lines changed

components/explorer/ExplorerMap.tsx

Lines changed: 9 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -357,11 +357,12 @@ export function ExplorerMap({ mapboxAccessToken }: ExplorerMapProps = {}) {
357357
const result: LayerSpec[] = [];
358358

359359
if (!isGridOperatorView && !hasHighlight) {
360-
// Utility territory tiles — aggressively zoom-gated for performance
361-
// 2,905 polygon features with ~125K vertices total (pre-simplified)
360+
// Utility territory tiles — zoom-gated for performance
361+
// 2,905 polygon features. geojson-vt provides zoom-dependent simplification:
362+
// rough shapes at low zoom, crisp detail at high zoom.
362363
//
363364
// Zoom 7-8: Large utilities only (>50k customers, ~355 features), fill-only
364-
// Zoom 9+: All territories, fill-only (no borders — they double draw calls)
365+
// Zoom 9+: All territories with full boundary detail, fill-only
365366
const largeFilter: any[] = [">", ["get", "customerCount"], 50000];
366367
const largeFilterExpr = territoryFilter
367368
? ["all", largeFilter, ...(Array.isArray(territoryFilter) && territoryFilter[0] === "all" ? territoryFilter.slice(1) : [territoryFilter])]
@@ -443,58 +444,22 @@ export function ExplorerMap({ mapboxAccessToken }: ExplorerMapProps = {}) {
443444
);
444445
}
445446

446-
// Power plant point layers (zoom-gated)
447-
// Layer 1: Major plants (>500 MW) visible at zoom 8-9
447+
// Power plants — single layer at zoom 8+ (no maxZoom gap = no flashing)
448+
// Points are lightweight; a single layer avoids the tile-loading race
449+
// that caused plants to flash in/out during zoom transitions.
448450
result.push(
449451
layer.vector({
450-
id: "power-plants-major",
452+
id: "power-plants",
451453
tileset: getPowerPlantTileUrl(),
452454
sourceLayer: "power-plants",
453455
renderAs: "circle",
454456
minZoom: 8,
455-
maxZoom: 9,
456-
filter: [">", ["get", "capacityMw"], 500],
457-
style: {
458-
color: { by: "fuelCategory", mapping: fuelCategoryColorMapping },
459-
radius: 5,
460-
borderWidth: 1,
461-
borderColor: { hex: "#ffffff" },
462-
fillOpacity: 0.9,
463-
},
464-
tooltip: {
465-
trigger: "hover",
466-
content: (feature: LayerFeature) => (
467-
<div className="flex flex-col gap-0.5">
468-
<span className="font-medium text-sm">{feature.properties.name}</span>
469-
<span className="text-xs text-gray-500">
470-
{feature.properties.fuelCategory} · {Math.round(feature.properties.capacityMw)} MW
471-
</span>
472-
</div>
473-
),
474-
},
475-
events: {
476-
onClick: (feature: LayerFeature) => {
477-
const slug = feature.properties.slug;
478-
if (slug) router.push(`/power-plants/${slug}`);
479-
},
480-
},
481-
})
482-
);
483-
484-
// Layer 2: All plants visible at zoom 10+
485-
result.push(
486-
layer.vector({
487-
id: "power-plants-all",
488-
tileset: getPowerPlantTileUrl(),
489-
sourceLayer: "power-plants",
490-
renderAs: "circle",
491-
minZoom: 10,
492457
style: {
493458
color: { by: "fuelCategory", mapping: fuelCategoryColorMapping },
494459
radius: 4,
495460
borderWidth: 1,
496461
borderColor: { hex: "#ffffff" },
497-
fillOpacity: 0.85,
462+
fillOpacity: 0.9,
498463
},
499464
tooltip: {
500465
trigger: "hover",

package-lock.json

Lines changed: 0 additions & 112 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@
2525
},
2626
"devDependencies": {
2727
"@biomejs/biome": "^2.3.0",
28-
"@turf/helpers": "^7.3.4",
29-
"@turf/simplify": "^7.3.4",
3028
"@types/adm-zip": "^0.5.7",
3129
"@types/geojson": "^7946.0.16",
3230
"@types/geojson-vt": "^3.2.2",

scripts/generate-tiles.mjs

Lines changed: 16 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
/**
22
* Pre-generates static vector tiles (MVT/PBF) at build time.
33
*
4-
* Reads territory GeoJSON + regions + utilities, pre-simplifies the geometry
5-
* using @turf/simplify to reduce vertex count (~2.5M → ~100-200K), then builds
6-
* a geojson-vt index and writes .pbf tiles for zoom 5–10.
4+
* Reads territory GeoJSON + regions + utilities, builds a geojson-vt index
5+
* and writes .pbf tiles for zoom 5–10.
6+
*
7+
* geojson-vt handles zoom-dependent simplification automatically:
8+
* - Low zoom (5-6): heavy simplification → fast rendering of many polygons
9+
* - High zoom (9-10): minimal simplification → crisp boundary detail
10+
*
11+
* No pre-simplification needed — the zoom gating in ExplorerMap.tsx ensures
12+
* only ~355 large territories render at zoom 7-8, and at zoom 9+ the viewport
13+
* is small enough that full-detail geometry is fine.
714
*/
815
import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
916
import { join } from "node:path";
1017
import geojsonvt from "geojson-vt";
1118
import vtpbf from "vt-pbf";
12-
import simplify from "@turf/simplify";
1319

1420
const { fromGeojsonVt } = vtpbf;
1521

@@ -19,17 +25,6 @@ const DATA_DIR = join(process.cwd(), "data");
1925
const TERRITORIES_DIR = join(DATA_DIR, "territories");
2026
const OUT_DIR = join(process.cwd(), "public", "tiles");
2127

22-
// Simplification tolerance in degrees (~0.05° ≈ 5.5km)
23-
// Aggressively reduces vertex count for tile rendering.
24-
// At zoom 5-10, territories look clean — exact boundaries aren't needed for the overview.
25-
// Detail panels load the original full-resolution GeoJSON when you click into a utility.
26-
const SIMPLIFY_TOLERANCE = 0.05;
27-
28-
function countVertices(geometry) {
29-
const coords = JSON.stringify(geometry.coordinates || []);
30-
return (coords.match(/\[[\d.-]+,[\d.-]+\]/g) || []).length;
31-
}
32-
3328
async function buildFeatureCollection() {
3429
const [regionsRaw, utilitiesRaw] = await Promise.all([
3530
readFile(join(DATA_DIR, "regions.json"), "utf-8"),
@@ -54,8 +49,6 @@ async function buildFeatureCollection() {
5449

5550
const files = (await readdir(TERRITORIES_DIR)).filter((f) => f.endsWith(".json"));
5651
const allFeatures = [];
57-
let totalVertsBefore = 0;
58-
let totalVertsAfter = 0;
5952

6053
for (const file of files) {
6154
try {
@@ -79,40 +72,18 @@ async function buildFeatureCollection() {
7972
};
8073

8174
for (const feature of geojson.features) {
82-
const vertsBefore = countVertices(feature.geometry);
83-
totalVertsBefore += vertsBefore;
84-
85-
// Pre-simplify complex geometries using Douglas-Peucker
86-
let geometry = feature.geometry;
87-
if (vertsBefore > 50) {
88-
try {
89-
const simplified = simplify(
90-
{ type: "Feature", properties: {}, geometry },
91-
{ tolerance: SIMPLIFY_TOLERANCE, highQuality: true, mutate: false }
92-
);
93-
geometry = simplified.geometry;
94-
} catch {
95-
// Keep original if simplification fails
96-
}
97-
}
98-
99-
const vertsAfter = countVertices(geometry);
100-
totalVertsAfter += vertsAfter;
101-
10275
allFeatures.push({
10376
type: "Feature",
10477
properties,
105-
geometry,
78+
geometry: feature.geometry,
10679
});
10780
}
10881
} catch {
10982
// Malformed territory files — safe to skip
11083
}
11184
}
11285

113-
const reduction = ((1 - totalVertsAfter / totalVertsBefore) * 100).toFixed(1);
11486
console.log(`✅ Loaded ${allFeatures.length} features from ${files.length} territory files`);
115-
console.log(` Vertices: ${totalVertsBefore.toLocaleString()}${totalVertsAfter.toLocaleString()} (${reduction}% reduction)`);
11687

11788
return {
11889
type: "FeatureCollection",
@@ -124,6 +95,10 @@ async function generateTiles() {
12495
const fc = await buildFeatureCollection();
12596

12697
console.log("Building geojson-vt index...");
98+
// tolerance 3 = default. geojson-vt automatically applies MORE simplification
99+
// at lower zoom levels and LESS at higher zoom levels. This gives us:
100+
// - Zoom 5-6: rough shapes (good — few pixels per territory)
101+
// - Zoom 9-10: crisp detailed boundaries (good — few territories visible)
127102
const index = geojsonvt(fc, {
128103
maxZoom: 14,
129104
tolerance: 3,
@@ -134,7 +109,7 @@ async function generateTiles() {
134109
let totalTiles = 0;
135110

136111
for (let z = MIN_ZOOM; z <= MAX_ZOOM; z++) {
137-
const maxCoord = 1 << z; // 2^z
112+
const maxCoord = 1 << z;
138113
let zoomTiles = 0;
139114

140115
for (let x = 0; x < maxCoord; x++) {

tsconfig.tsbuildinfo

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)