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 ") 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 }