Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 9 additions & 44 deletions components/explorer/ExplorerMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,11 +357,12 @@ export function ExplorerMap({ mapboxAccessToken }: ExplorerMapProps = {}) {
const result: LayerSpec[] = [];

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

// Power plant point layers (zoom-gated)
// Layer 1: Major plants (>500 MW) visible at zoom 8-9
// Power plants — single layer at zoom 8+ (no maxZoom gap = no flashing)
// Points are lightweight; a single layer avoids the tile-loading race
// that caused plants to flash in/out during zoom transitions.
result.push(
layer.vector({
id: "power-plants-major",
id: "power-plants",
tileset: getPowerPlantTileUrl(),
sourceLayer: "power-plants",
renderAs: "circle",
minZoom: 8,
maxZoom: 9,
filter: [">", ["get", "capacityMw"], 500],
style: {
color: { by: "fuelCategory", mapping: fuelCategoryColorMapping },
radius: 5,
borderWidth: 1,
borderColor: { hex: "#ffffff" },
fillOpacity: 0.9,
},
tooltip: {
trigger: "hover",
content: (feature: LayerFeature) => (
<div className="flex flex-col gap-0.5">
<span className="font-medium text-sm">{feature.properties.name}</span>
<span className="text-xs text-gray-500">
{feature.properties.fuelCategory} · {Math.round(feature.properties.capacityMw)} MW
</span>
</div>
),
},
events: {
onClick: (feature: LayerFeature) => {
const slug = feature.properties.slug;
if (slug) router.push(`/power-plants/${slug}`);
},
},
})
);

// Layer 2: All plants visible at zoom 10+
result.push(
layer.vector({
id: "power-plants-all",
tileset: getPowerPlantTileUrl(),
sourceLayer: "power-plants",
renderAs: "circle",
minZoom: 10,
style: {
color: { by: "fuelCategory", mapping: fuelCategoryColorMapping },
radius: 4,
borderWidth: 1,
borderColor: { hex: "#ffffff" },
fillOpacity: 0.85,
fillOpacity: 0.9,
},
tooltip: {
trigger: "hover",
Expand Down
112 changes: 0 additions & 112 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.0",
"@turf/helpers": "^7.3.4",
"@turf/simplify": "^7.3.4",
"@types/adm-zip": "^0.5.7",
"@types/geojson": "^7946.0.16",
"@types/geojson-vt": "^3.2.2",
Expand Down
57 changes: 16 additions & 41 deletions scripts/generate-tiles.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
/**
* Pre-generates static vector tiles (MVT/PBF) at build time.
*
* Reads territory GeoJSON + regions + utilities, pre-simplifies the geometry
* using @turf/simplify to reduce vertex count (~2.5M → ~100-200K), then builds
* a geojson-vt index and writes .pbf tiles for zoom 5–10.
* Reads territory GeoJSON + regions + utilities, builds a geojson-vt index
* and writes .pbf tiles for zoom 5–10.
*
* geojson-vt handles zoom-dependent simplification automatically:
* - Low zoom (5-6): heavy simplification → fast rendering of many polygons
* - High zoom (9-10): minimal simplification → crisp boundary detail
*
* No pre-simplification needed — the zoom gating in ExplorerMap.tsx ensures
* only ~355 large territories render at zoom 7-8, and at zoom 9+ the viewport
* is small enough that full-detail geometry is fine.
*/
import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import geojsonvt from "geojson-vt";
import vtpbf from "vt-pbf";
import simplify from "@turf/simplify";

const { fromGeojsonVt } = vtpbf;

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

// Simplification tolerance in degrees (~0.05° ≈ 5.5km)
// Aggressively reduces vertex count for tile rendering.
// At zoom 5-10, territories look clean — exact boundaries aren't needed for the overview.
// Detail panels load the original full-resolution GeoJSON when you click into a utility.
const SIMPLIFY_TOLERANCE = 0.05;

function countVertices(geometry) {
const coords = JSON.stringify(geometry.coordinates || []);
return (coords.match(/\[[\d.-]+,[\d.-]+\]/g) || []).length;
}

async function buildFeatureCollection() {
const [regionsRaw, utilitiesRaw] = await Promise.all([
readFile(join(DATA_DIR, "regions.json"), "utf-8"),
Expand All @@ -54,8 +49,6 @@ async function buildFeatureCollection() {

const files = (await readdir(TERRITORIES_DIR)).filter((f) => f.endsWith(".json"));
const allFeatures = [];
let totalVertsBefore = 0;
let totalVertsAfter = 0;

for (const file of files) {
try {
Expand All @@ -79,40 +72,18 @@ async function buildFeatureCollection() {
};

for (const feature of geojson.features) {
const vertsBefore = countVertices(feature.geometry);
totalVertsBefore += vertsBefore;

// Pre-simplify complex geometries using Douglas-Peucker
let geometry = feature.geometry;
if (vertsBefore > 50) {
try {
const simplified = simplify(
{ type: "Feature", properties: {}, geometry },
{ tolerance: SIMPLIFY_TOLERANCE, highQuality: true, mutate: false }
);
geometry = simplified.geometry;
} catch {
// Keep original if simplification fails
}
}

const vertsAfter = countVertices(geometry);
totalVertsAfter += vertsAfter;

allFeatures.push({
type: "Feature",
properties,
geometry,
geometry: feature.geometry,
});
}
} catch {
// Malformed territory files — safe to skip
}
}

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

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

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

for (let z = MIN_ZOOM; z <= MAX_ZOOM; z++) {
const maxCoord = 1 << z; // 2^z
const maxCoord = 1 << z;
let zoomTiles = 0;

for (let x = 0; x < maxCoord; x++) {
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.tsbuildinfo

Large diffs are not rendered by default.