You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
319 lines
11 KiB
319 lines
11 KiB
import fs from "node:fs"
|
|
import path from "node:path"
|
|
import process from "node:process"
|
|
|
|
const projectRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..")
|
|
const sourcePath = process.argv[2]
|
|
const targetPath = path.join(projectRoot, "public/datas/yak-industry-chain/artificial_grassland_plots.geojson")
|
|
const summaryPath = path.join(projectRoot, "src/views/yakIndustryChain/artificialGrasslandSummary.json")
|
|
const townshipPath = path.join(projectRoot, "public/datas/geojsons/hongyuan-townships.geojson")
|
|
const villagePath = path.join(projectRoot, "public/datas/geojsons/hongyuan-villages.geojson")
|
|
|
|
if (!sourcePath) {
|
|
console.error("Usage: node scripts/prepare-artificial-grassland-data.mjs <source.geojson>")
|
|
process.exit(1)
|
|
}
|
|
|
|
const source = readJson(sourcePath)
|
|
const townships = readJson(townshipPath)
|
|
const villages = fs.existsSync(villagePath) ? readJson(villagePath) : { features: [] }
|
|
const sourceLabel = "21.95万亩种草矢量数据"
|
|
const displayTownOrder = (townships.features || []).map((feature) => feature.properties?.name).filter(Boolean)
|
|
|
|
const seenTopology = new Set()
|
|
const features = []
|
|
|
|
for (const sourceFeature of source.features || []) {
|
|
const candidate = toPlotCandidate(sourceFeature)
|
|
if (!candidate) continue
|
|
const topologyKey = canonicalRing(candidate.ring)
|
|
if (!topologyKey || seenTopology.has(topologyKey)) continue
|
|
seenTopology.add(topologyKey)
|
|
|
|
const name = cleanName(sourceFeature.properties?.name)
|
|
const category = getPlotCategory(name)
|
|
const center = getRingCenter(candidate.ring)
|
|
const town = getTownByPoint(center, townships.features || [])
|
|
const village = getVillageByPoint(center, villages.features || [], town)
|
|
const areaSquareMeters = getPolygonAreaSquareMeters([candidate.ring])
|
|
const areaMu = roundNumber(areaSquareMeters / 666.6666667, 2)
|
|
const id = `AG-${String(features.length + 1).padStart(3, "0")}`
|
|
|
|
features.push({
|
|
type: "Feature",
|
|
properties: {
|
|
id,
|
|
plot_code: id,
|
|
name,
|
|
plot_name: name,
|
|
stageKey: "artificial-grassland",
|
|
stage_name: "人工种草",
|
|
town,
|
|
town_name: town,
|
|
village,
|
|
village_name: village,
|
|
area_mu: areaMu,
|
|
area_m2: roundNumber(areaSquareMeters, 2),
|
|
plot_category: category.name,
|
|
plot_category_key: category.key,
|
|
data_status: "待核定",
|
|
source_type: candidate.sourceType,
|
|
data_source: sourceLabel,
|
|
geometry_note: candidate.note,
|
|
},
|
|
geometry: {
|
|
type: "Polygon",
|
|
coordinates: [closeRing(candidate.ring)],
|
|
},
|
|
})
|
|
}
|
|
|
|
const summary = buildSummary(features, displayTownOrder)
|
|
writeJson(targetPath, {
|
|
type: "FeatureCollection",
|
|
name: "artificial_grassland_plots",
|
|
generatedAt: new Date().toISOString(),
|
|
description: "人工种草空间资料。图斑为待核定资料,不作为正式面积统计口径。",
|
|
features,
|
|
})
|
|
writeJson(summaryPath, summary)
|
|
|
|
console.log(`Generated ${features.length} artificial grassland plots.`)
|
|
console.log(`GeoJSON: ${targetPath}`)
|
|
console.log(`Summary: ${summaryPath}`)
|
|
|
|
function readJson(filePath) {
|
|
return JSON.parse(fs.readFileSync(filePath, "utf8"))
|
|
}
|
|
|
|
function writeJson(filePath, value) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8")
|
|
}
|
|
|
|
function toPlotCandidate(feature = {}) {
|
|
const geometry = feature.geometry || {}
|
|
if (geometry.type === "Polygon") {
|
|
const ring = cleanRing(geometry.coordinates?.[0])
|
|
if (ring.length < 3) return null
|
|
return {
|
|
ring,
|
|
sourceType: "面图斑",
|
|
note: "原始面图斑已闭合后展示",
|
|
}
|
|
}
|
|
if (geometry.type !== "LineString") return null
|
|
const originalRing = Array.isArray(geometry.coordinates) ? geometry.coordinates : []
|
|
if (originalRing.length < 4 || !samePoint(originalRing[0], originalRing.at(-1))) return null
|
|
const ring = cleanRing(originalRing)
|
|
if (ring.length < 3) return null
|
|
return {
|
|
ring,
|
|
sourceType: "边界图斑",
|
|
note: "原始闭合边界转换为图斑展示",
|
|
}
|
|
}
|
|
|
|
function cleanRing(coordinates = []) {
|
|
const ring = []
|
|
coordinates.forEach((coordinate) => {
|
|
if (!Array.isArray(coordinate) || coordinate.length < 2) return
|
|
const next = [roundCoordinate(coordinate[0]), roundCoordinate(coordinate[1])]
|
|
if (!ring.length || !samePoint(ring.at(-1), next)) ring.push(next)
|
|
})
|
|
if (ring.length > 1 && samePoint(ring[0], ring.at(-1))) ring.pop()
|
|
return ring
|
|
}
|
|
|
|
function closeRing(ring = []) {
|
|
return [...ring, [...ring[0]]]
|
|
}
|
|
|
|
function canonicalRing(ring = []) {
|
|
if (ring.length < 3) return ""
|
|
const variants = [ring, [...ring].reverse()]
|
|
let result = ""
|
|
variants.forEach((variant) => {
|
|
variant.forEach((_, index) => {
|
|
const rotated = [...variant.slice(index), ...variant.slice(0, index)]
|
|
const signature = rotated.map((point) => `${point[0]},${point[1]}`).join("|")
|
|
if (!result || signature < result) result = signature
|
|
})
|
|
})
|
|
return result
|
|
}
|
|
|
|
function getRingCenter(ring = []) {
|
|
if (!ring.length) return [0, 0]
|
|
const total = ring.reduce(
|
|
(result, point) => [result[0] + Number(point[0] || 0), result[1] + Number(point[1] || 0)],
|
|
[0, 0],
|
|
)
|
|
return [total[0] / ring.length, total[1] / ring.length]
|
|
}
|
|
|
|
function getTownByPoint(point, townshipFeatures) {
|
|
for (const feature of townshipFeatures) {
|
|
const geometry = feature.geometry || {}
|
|
const polygons = geometry.type === "MultiPolygon" ? geometry.coordinates : geometry.type === "Polygon" ? [geometry.coordinates] : []
|
|
if (polygons.some((polygon) => isPointInRing(point, polygon?.[0] || []))) {
|
|
return feature.properties?.name || "未归属"
|
|
}
|
|
}
|
|
return "未归属"
|
|
}
|
|
|
|
function getVillageByPoint(point, villageFeatures, town) {
|
|
for (const feature of villageFeatures) {
|
|
const properties = feature.properties || {}
|
|
if (town && properties.town && properties.town !== town) continue
|
|
const geometry = feature.geometry || {}
|
|
const polygons = geometry.type === "MultiPolygon" ? geometry.coordinates : geometry.type === "Polygon" ? [geometry.coordinates] : []
|
|
if (polygons.some((polygon) => isPointInRing(point, polygon?.[0] || []))) {
|
|
return properties.name || properties.village || ""
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
function isPointInRing(point, ring = []) {
|
|
if (!Array.isArray(point) || ring.length < 3) return false
|
|
let inside = false
|
|
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
|
const [currentX, currentY] = ring[index]
|
|
const [previousX, previousY] = ring[previous]
|
|
const crosses = (currentY > point[1]) !== (previousY > point[1])
|
|
&& point[0] < ((previousX - currentX) * (point[1] - currentY)) / (previousY - currentY) + currentX
|
|
if (crosses) inside = !inside
|
|
}
|
|
return inside
|
|
}
|
|
|
|
function getPolygonAreaSquareMeters(rings = []) {
|
|
if (!rings.length) return 0
|
|
const [outer, ...holes] = rings
|
|
const outerArea = Math.abs(getRingAreaSquareMeters(outer))
|
|
const holesArea = holes.reduce((sum, ring) => sum + Math.abs(getRingAreaSquareMeters(ring)), 0)
|
|
return Math.max(0, outerArea - holesArea)
|
|
}
|
|
|
|
function getRingAreaSquareMeters(ring = []) {
|
|
if (ring.length < 3) return 0
|
|
const averageLat = ring.reduce((sum, point) => sum + Number(point?.[1] || 0), 0) / ring.length
|
|
const metersPerDegree = 111320
|
|
const xScale = metersPerDegree * Math.cos((averageLat * Math.PI) / 180)
|
|
let area = 0
|
|
for (let index = 0; index < ring.length; index++) {
|
|
const current = ring[index]
|
|
const next = ring[(index + 1) % ring.length]
|
|
const x1 = Number(current?.[0] || 0) * xScale
|
|
const y1 = Number(current?.[1] || 0) * metersPerDegree
|
|
const x2 = Number(next?.[0] || 0) * xScale
|
|
const y2 = Number(next?.[1] || 0) * metersPerDegree
|
|
area += x1 * y2 - x2 * y1
|
|
}
|
|
return area / 2
|
|
}
|
|
|
|
function getPlotCategory(name) {
|
|
if (name.startsWith("撒播")) return { key: "broadcast", name: "撒播图斑" }
|
|
if (name.startsWith("总范围")) return { key: "high-yield", name: "高产稳产范围" }
|
|
if (name.startsWith("调拨")) return { key: "allocation", name: "调拨地块" }
|
|
if (name.includes("地块")) return { key: "project-plot", name: "项目地块" }
|
|
return { key: "unclassified", name: "待分类图斑" }
|
|
}
|
|
|
|
function buildSummary(plotFeatures, townOrder) {
|
|
const rows = plotFeatures.map((feature) => feature.properties || {})
|
|
const townRows = townOrder.map((name) => {
|
|
const value = rows.filter((item) => item.town === name).length
|
|
return {
|
|
key: name,
|
|
name,
|
|
value,
|
|
unit: "个",
|
|
extra: value ? "待核定图斑" : "暂无图斑",
|
|
zero: value === 0,
|
|
}
|
|
})
|
|
const categoryRows = groupRows(rows, "plot_category").map((item) => ({
|
|
...item,
|
|
unit: "个",
|
|
}))
|
|
const sourceRows = groupRows(rows, "source_type").map((item) => ({
|
|
...item,
|
|
unit: "个",
|
|
}))
|
|
const plotRows = rows
|
|
.map((item) => ({
|
|
key: item.id,
|
|
id: item.id,
|
|
name: item.name,
|
|
fullName: item.name,
|
|
town: item.town,
|
|
village: item.village,
|
|
category: item.plot_category,
|
|
status: item.data_status,
|
|
areaMu: item.area_mu,
|
|
value: item.area_mu,
|
|
unit: "亩",
|
|
valueText: `${formatNumber(item.area_mu)}亩`,
|
|
stageKey: "artificial-grassland",
|
|
stageName: "人工种草",
|
|
canLocate: true,
|
|
locateText: "定位",
|
|
searchText: [item.name, item.town, item.village, item.plot_category, item.data_status, item.area_mu].filter(Boolean).join(" "),
|
|
}))
|
|
.sort((left, right) => Number(right.category === "项目地块") - Number(left.category === "项目地块") || left.name.localeCompare(right.name, "zh-CN"))
|
|
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
sourceLabel,
|
|
statusLabel: "待核定",
|
|
totalPlots: rows.length,
|
|
coveredTowns: townRows.filter((item) => item.value > 0).length,
|
|
projectPlots: rows.filter((item) => item.plot_category === "项目地块").length,
|
|
pendingPlots: rows.length,
|
|
townRows,
|
|
categoryRows,
|
|
sourceRows,
|
|
plotRows,
|
|
}
|
|
}
|
|
|
|
function groupRows(rows, key) {
|
|
const grouped = new Map()
|
|
rows.forEach((item) => {
|
|
const name = String(item[key] || "未分类")
|
|
grouped.set(name, (grouped.get(name) || 0) + 1)
|
|
})
|
|
return [...grouped.entries()]
|
|
.map(([name, value]) => ({ key: name, name, value }))
|
|
.sort((left, right) => right.value - left.value || left.name.localeCompare(right.name, "zh-CN"))
|
|
}
|
|
|
|
function cleanName(value) {
|
|
const text = String(value || "").trim()
|
|
return text || "未命名图斑"
|
|
}
|
|
|
|
function roundCoordinate(value) {
|
|
return Number(Number(value).toFixed(8))
|
|
}
|
|
|
|
function roundNumber(value, digits = 2) {
|
|
const number = Number(value)
|
|
if (!Number.isFinite(number)) return 0
|
|
const factor = 10 ** digits
|
|
return Math.round(number * factor) / factor
|
|
}
|
|
|
|
function formatNumber(value) {
|
|
const number = Number(value || 0)
|
|
if (!Number.isFinite(number)) return "0"
|
|
return Number.isInteger(number) ? String(number) : String(roundNumber(number, 2))
|
|
}
|
|
|
|
function samePoint(left = [], right = []) {
|
|
return Math.abs(Number(left[0]) - Number(right[0])) < 1e-8 && Math.abs(Number(left[1]) - Number(right[1])) < 1e-8
|
|
}
|
|
|