city-atlas.mjs 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. // Offline city inventory. WGS84 bounds are the explicitly agreed deployment area, not GIS tile coverage.
  2. import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
  3. import { resolve } from 'node:path'
  4. import { PNG } from 'pngjs'
  5. import jpeg from 'jpeg-js'
  6. import * as turf from '@turf/turf'
  7. import { PRIMARY_AREA_BOUNDS, clipFeatureToBounds } from './primary-area.mjs'
  8. const DIR = process.env.GIS_DIR || 'D:/GIS/GIS'
  9. const OUT = resolve('tools/city-massing/city')
  10. const BOUNDS = PRIMARY_AREA_BOUNDS
  11. const ZOOM = 15
  12. const TILE = 256
  13. mkdirSync(OUT, { recursive: true })
  14. const slippy = ([lon, lat]) => [
  15. ((lon + 180) / 360) * 2 ** ZOOM,
  16. ((1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2) * 2 ** ZOOM,
  17. ]
  18. const nw = slippy([BOUNDS[0], BOUNDS[3]])
  19. const se = slippy([BOUNDS[2], BOUNDS[1]])
  20. const [west, north, east, south] = [Math.floor(nw[0]), Math.floor(nw[1]), Math.ceil(se[0]), Math.ceil(se[1])]
  21. const image = new PNG({ width: (east - west) * TILE, height: (south - north) * TILE })
  22. image.data.fill(230)
  23. let tiles = 0
  24. const missing = []
  25. for (let y = north; y < south; y++)
  26. for (let x = west; x < east; x++) {
  27. const file = `${DIR}/yuanlingPic3/${ZOOM}/${x}/${2 ** ZOOM - 1 - y}.png`
  28. if (!existsSync(file)) {
  29. missing.push([x, y])
  30. continue
  31. }
  32. const bytes = readFileSync(file)
  33. const decoded = bytes[0] === 0xff ? jpeg.decode(bytes) : PNG.sync.read(bytes)
  34. PNG.bitblt(decoded, image, 0, 0, TILE, TILE, (x - west) * TILE, (y - north) * TILE)
  35. tiles++
  36. }
  37. if (missing.length) throw new Error(`City atlas incomplete: ${missing.length} image tiles missing`)
  38. writeFileSync(resolve(OUT, 'city-satellite.png'), PNG.sync.write(image))
  39. const window = turf.bboxPolygon(BOUNDS)
  40. function clippedFeatures(source) {
  41. const featureCollection = JSON.parse(readFileSync(source, 'utf8'))
  42. const result = []
  43. for (const feature of featureCollection.features) {
  44. try {
  45. const clipped = clipFeatureToBounds(feature, BOUNDS)
  46. if (clipped) result.push(clipped)
  47. } catch (error) {
  48. throw new Error(`Cannot clip ${source}: ${error.message}`)
  49. }
  50. }
  51. return result
  52. }
  53. const residential = clippedFeatures(`${DIR}/output/resa.json`)
  54. const water = clippedFeatures(`${DIR}/output/hyda.json`)
  55. const osmBase = `${DIR}/planet_110.059,28.258_110.744,28.625-shp/shpOutput`
  56. const osmBuildings = clippedFeatures(`${osmBase}/buildings.json`)
  57. const osmRoads = clippedFeatures(`${osmBase}/roads.json`)
  58. const osmLanduse = clippedFeatures(`${osmBase}/landuse.json`)
  59. const sections = [
  60. { id: 'south', label: '城南', bbox: [BOUNDS[0], BOUNDS[1], BOUNDS[2], 28.432] },
  61. { id: 'river-central', label: '沿江老城区', bbox: [BOUNDS[0], 28.432, BOUNDS[2], 28.455] },
  62. { id: 'north', label: '城北', bbox: [BOUNDS[0], 28.455, BOUNDS[2], BOUNDS[3]] },
  63. ]
  64. const districts = sections.map((section) => {
  65. const bbox = turf.bboxPolygon(section.bbox)
  66. const inside = (xs) => xs.filter((x) => turf.booleanIntersects(x, bbox))
  67. const masks = inside(residential)
  68. return {
  69. ...section,
  70. residentialIds: masks.map((x) => x.properties.ORIG_FID),
  71. residentialSqKm: Math.round(masks.reduce((s, x) => s + turf.area(x), 0) / 10000) / 100,
  72. osmBuildingCount: inside(osmBuildings).length,
  73. roadCount: inside(osmRoads).length,
  74. status: 'unreviewed',
  75. priority: section.id === 'south' ? 'regular-first' : 'old-town-and-regular',
  76. }
  77. })
  78. const atlas = {
  79. source: 'yuanlingPic3 TMS + output/resa.json residential polygon + OSM buildings/roads',
  80. bounds: BOUNDS,
  81. imagery: {
  82. zoom: ZOOM,
  83. westTile: west,
  84. googleY0: north,
  85. tmsTopY: 2 ** ZOOM - 1 - north,
  86. tileCount: tiles,
  87. width: image.width,
  88. height: image.height,
  89. },
  90. coveragePolicy:
  91. 'Building coverage is measured only within independently verified urban masks; this inventory is not itself a modeled-building footprint catalog.',
  92. districts,
  93. originalSources: {
  94. residential: residential.length,
  95. osmBuildings: osmBuildings.length,
  96. osmRoads: osmRoads.length,
  97. water: water.length,
  98. osmLanduse: osmLanduse.length,
  99. },
  100. }
  101. writeFileSync(resolve(OUT, 'city-inventory.json'), JSON.stringify(atlas, null, 2))
  102. writeFileSync(resolve(OUT, 'residential-clipped.geojson'), JSON.stringify(turf.featureCollection(residential)))
  103. writeFileSync(resolve(OUT, 'water-clipped.geojson'), JSON.stringify(turf.featureCollection(water)))
  104. writeFileSync(resolve(OUT, 'osm-buildings-clipped.geojson'), JSON.stringify(turf.featureCollection(osmBuildings)))
  105. writeFileSync(resolve(OUT, 'roads-clipped.geojson'), JSON.stringify(turf.featureCollection(osmRoads)))
  106. console.log(JSON.stringify({ tiles, atlasPixels: [image.width, image.height], ...atlas.originalSources, districts }))