import {readFile,writeFile,mkdir,copyFile} from 'node:fs/promises'; import {resolve} from 'node:path'; import {topology} from 'topojson-server'; import {feature} from 'topojson-client'; import {presimplify,quantile,simplify} from 'topojson-simplify'; import {geoConicEqualArea,geoPath,geoArea} from 'd3-geo'; import {createHash} from 'node:crypto'; const root=resolve(import.meta.dirname,'../..'); const source=JSON.parse(await readFile(resolve(root,'assets/geo/geoBoundaries-RUS-ADM1_simplified.geojson'),'utf8')); if(source.type!=='FeatureCollection'||source.features.length!==83)throw Error('Expected pinned RUS ADM1 / 83 features'); let coordinates=0;const bounds=[Infinity,Infinity,-Infinity,-Infinity];let westOf180=0; function inspect(a){if(typeof a[0]==='number'){ if(!Number.isFinite(a[0])||!Number.isFinite(a[1])||Math.abs(a[0])>180||Math.abs(a[1])>90)throw Error('Invalid WGS84 coordinate'); coordinates++;bounds[0]=Math.min(bounds[0],a[0]);bounds[1]=Math.min(bounds[1],a[1]);bounds[2]=Math.max(bounds[2],a[0]);bounds[3]=Math.max(bounds[3],a[1]);if(a[0]<-160)westOf180++; }else a.forEach(inspect);} source.features.forEach(f=>{if(!['Polygon','MultiPolygon'].includes(f.geometry.type))throw Error('Unexpected geometry');inspect(f.geometry.coordinates);}); const prepared=presimplify(topology({regions:source},100000)); const weight=quantile(prepared,0.06); const topo=simplify(prepared,weight); let light=feature(topo,topo.objects.regions); // D3 expects clockwise exterior spherical rings; normalize for projection only. // Keep original source unchanged; small regional polygons must cover < hemisphere. for(const f of light.features){if(geoArea(f)>2*Math.PI){ const polygons=f.geometry.type==='Polygon'?[f.geometry.coordinates]:f.geometry.coordinates; for(const p of polygons)for(const ring of p)ring.reverse(); }} if(light.features.length!==83)throw Error('Lost regions'); const projection=geoConicEqualArea().parallels([50,70]).rotate([-105,0]).center([0,60]).fitExtent([[20,20],[1180,690]],light); const path=geoPath(projection).digits(1); const regions=light.features.map(f=>({id:f.properties.shapeISO||f.properties.shapeID,name:f.properties.shapeName,sourceID:f.properties.shapeID,path:path(f),labelPoint:path.centroid(f)})); if(regions.some(r=>!r.path||r.path.includes('NaN')||r.labelPoint.some(v=>!Number.isFinite(v))))throw Error('Invalid projected geometry'); for(const f of light.features){function round(a){if(typeof a[0]==='number'){a[0]=+a[0].toFixed(5);a[1]=+a[1].toFixed(5);}else a.forEach(round);}round(f.geometry.coordinates);} await writeFile(resolve(root,'assets/geo/russia-regions.geojson'),JSON.stringify(light)); await writeFile(resolve(root,'assets/geo/russia-regions.paths.json'),JSON.stringify({viewBox:'0 0 1200 710',projection:'Conic equal area, parallels 50/70, central meridian 105 E',regions})); const escape=s=>s.replaceAll('&','&').replaceAll('<','<').replaceAll('"','"'); const svg=`Russia ADM1 source geometry diagnostic — geoBoundaries / OSM${regions.map(r=>`${escape(r.name)}`).join('')}geoBoundaries / OpenStreetMap contributors. Dataset: 2017; 83 units. Geometry diagnostic.`; await writeFile(resolve(root,'dev/artifacts/setup/russia-geometry.svg'),svg); const report={date:new Date().toISOString(),features:83,sourceCoordinates:coordinates,sourceBounds:bounds,antimeridianCoordinates:westOf180,sourceYear:2017,retainedWeightedFraction:0.06,weight,projectedPaths:regions.length,sourceSHA256:createHash('sha256').update(await readFile(resolve(root,'assets/geo/geoBoundaries-RUS-ADM1_simplified.geojson'))).digest('hex'),files:{geojson:Buffer.byteLength(JSON.stringify(light)),paths:Buffer.byteLength(JSON.stringify(regions))},names:regions.map(({id,name})=>({id,name}))}; await writeFile(resolve(root,'dev/artifacts/setup/map-validation.json'),JSON.stringify(report,null,2)); for(const pkg of ['d3-geo','topojson-server','topojson-client','topojson-simplify']){ await copyFile(resolve(root,`dev/tools/node_modules/${pkg}/LICENSE`),resolve(root,`docs/licenses/${pkg}-LICENSE`)); } console.log({features:report.features,bounds,coordinates,antimeridianCoordinates:westOf180,files:report.files});