"use client"; import Link from "next/link"; import { CSSProperties, useEffect, useMemo, useState } from "react"; import { geoConicConformal, geoIdentity, geoPath } from "d3-geo"; import mapData from "../data/produce-map.json"; import styles from "./ProduceMap.module.css"; type Region = { name: string; localName: string; crops: string[]; season: string; summary: string }; type Tab = { id: string; label: string; country: string; iso: string; map: string; accent: string; description: string; regions: Region[] }; type Feature = { type: "Feature"; properties: { shapeName?: string }; geometry: unknown }; type FeatureCollection = { type: "FeatureCollection"; features: Feature[] }; type MapShape = { feature: Feature; d: string; centroid: [number, number]; name: string; key: string }; type DrilldownArea = { name: string; localName: string; crops: string[]; season: string; summary: string }; const tabs = mapData.tabs as Tab[]; const hebeiCities: DrilldownArea[] = [ { name: "Shijiazhuang City", localName: "石家庄市", crops: ["小麦", "玉米", "梨", "乳制品"], season: "6月—10月", summary: "太行山东麓平原农业集中,是河北中南部粮食与果品生产区。" }, { name: "Tangshan City", localName: "唐山市", crops: ["水稻", "玉米", "花生", "板栗"], season: "7月—10月", summary: "燕山南麓与滨海平原兼具粮食、油料和京东板栗等特色产品。" }, { name: "Qinhuangdao City", localName: "秦皇岛市", crops: ["葡萄", "板栗", "苹果", "水产"], season: "7月—10月", summary: "山海相邻,昌黎葡萄、燕山板栗及沿海水产品具有代表性。" }, { name: "Handan City", localName: "邯郸市", crops: ["小麦", "玉米", "棉花", "核桃"], season: "6月—10月", summary: "冀南平原适合粮棉种植,西部山区发展核桃等林果产品。" }, { name: "Xingtai City", localName: "邢台市", crops: ["板栗", "苹果", "小麦", "酸枣"], season: "6月—10月", summary: "太行山区林果与东部平原粮食生产并行,酸枣产业特色明显。" }, { name: "Baoding City", localName: "保定市", crops: ["草莓", "桃", "柿子", "小麦"], season: "5月—10月", summary: "山地、丘陵和平原类型齐全,形成多样化果品和粮食产区。" }, { name: "Zhangjiakou City", localName: "张家口市", crops: ["马铃薯", "燕麦", "杂豆", "葡萄"], season: "7月—10月", summary: "坝上高原气候冷凉,是马铃薯、燕麦和错季蔬菜优势产区。" }, { name: "Chengde City", localName: "承德市", crops: ["山楂", "板栗", "食用菌", "杂粮"], season: "7月—10月", summary: "燕山山地森林资源丰富,林果、菌类和旱作杂粮较有特色。" }, { name: "Cangzhou City", localName: "沧州市", crops: ["金丝小枣", "鸭梨", "玉米", "水产"], season: "8月—10月", summary: "滨海平原和盐碱地农业突出,以金丝小枣、鸭梨及水产著名。" }, { name: "Langfang City", localName: "廊坊市", crops: ["蔬菜", "梨", "葡萄", "食用菌"], season: "5月—11月", summary: "京津之间的都市近郊农业区,设施蔬菜、果品和菌类供应集中。" }, { name: "Hengshui City", localName: "衡水市", crops: ["小麦", "玉米", "棉花", "辣椒"], season: "6月—10月", summary: "黑龙港流域平原农业为主,粮棉与特色蔬菜种植规模较大。" } ]; const regionTranslations: Record> = { china: { "Shanghai Municipality": "上海市", "Chongqing Municipality": "重庆市" }, japan: { "Aichi Prefecture": "爱知县", Akita: "秋田县", Aomori: "青森县", Chiba: "千叶县", "Ehime Prefecture": "爱媛县", "Fukui Prefecture": "福井县", "Fukuoka Prefecture": "福冈县", Fukushima: "福岛县", "Gifu Prefecture": "岐阜县", Gunma: "群马县", Hiroshima: "广岛县", Hokkaido: "北海道", "Hyogo Prefecture": "兵库县", Ibaraki: "茨城县", "Ishikawa Prefecture": "石川县", Iwate: "岩手县", "Kagawa Prefecture": "香川县", "Kagoshima Prefecture": "鹿儿岛县", Kanagawa: "神奈川县", "Kochi Prefecture": "高知县", Kumamoto: "熊本县", "Kyoto Prefecture": "京都府", "Mie Prefecture": "三重县", Miyagi: "宫城县", "Miyazaki Prefecture": "宫崎县", Nagano: "长野县", "Nagasaki Prefecture": "长崎县", "Nara Prefecture": "奈良县", Niigata: "新潟县", Oita: "大分县", "Okayama Prefecture": "冈山县", "Okinawa Prefecture": "冲绳县", "Osaka Prefecture": "大阪府", "Saga Prefecture": "佐贺县", Saitama: "埼玉县", Shiga: "滋贺县", Shimane: "岛根县", Shizuoka: "静冈县", Tochigi: "栃木县", "Tokushima Prefecture": "德岛县", Tokyo: "东京都", "Tottori Prefecture": "鸟取县", Toyama: "富山县", "Wakayama Prefecture": "和歌山县", Yamagata: "山形县", Yamaguchi: "山口县", Yamanashi: "山梨县" } }; export default function ProduceMapPage() { const [activeId, setActiveId] = useState(tabs[0].id); const [geoData, setGeoData] = useState(null); const [selectedName, setSelectedName] = useState(tabs[0].regions[0].name); const [selectedMapName, setSelectedMapName] = useState(tabs[0].regions[0].name); const [hoveredName, setHoveredName] = useState(null); const [loading, setLoading] = useState(true); const [hebeiGeoData, setHebeiGeoData] = useState(null); const [selectedHebeiCity, setSelectedHebeiCity] = useState(hebeiCities[0].name); const [hoveredHebeiCity, setHoveredHebeiCity] = useState(null); const active = tabs.find((item) => item.id === activeId) ?? tabs[0]; const selected = active.regions.find((item) => item.name === selectedName) ?? null; const selectedDisplayName = selected?.localName ?? localRegionName(active.id, selectedMapName); const cropNames = useMemo(() => Array.from(new Set(active.regions.flatMap((item) => item.crops))), [active]); useEffect(() => { let cancelled = false; setLoading(true); setGeoData(null); setSelectedName(active.regions[0].name); setSelectedMapName(active.regions[0].name); fetch(active.map).then((response) => response.json()).then((data: FeatureCollection) => { if (!cancelled) { setGeoData(data); setLoading(false); } }).catch(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [active]); const mapShapes = useMemo(() => { if (!geoData) return []; return buildMapShapes(geoData, active.id); }, [geoData, active.id]); const selectedShape = mapShapes.find((shape) => shape.name === selectedMapName); const showHebeiDrilldown = active.id === "china" && selectedMapName === "Hebei Province"; const hebeiShapes = useMemo(() => hebeiGeoData ? projectMapFeatures(hebeiGeoData.features, [[44, 42], [956, 598]], "hebei-city") : [], [hebeiGeoData]); const selectedCity = hebeiCities.find((city) => city.name === selectedHebeiCity) ?? hebeiCities[0]; const selectedCityShape = hebeiShapes.find((shape) => shape.name === selectedHebeiCity); useEffect(() => { fetch("/maps/hebei-adm2.geojson").then((response) => response.json()).then((data: FeatureCollection) => setHebeiGeoData(data)).catch(() => setHebeiGeoData(null)); }, []); useEffect(() => { if (!showHebeiDrilldown) return; const frame = requestAnimationFrame(() => document.getElementById("province-detail")?.scrollIntoView({ behavior: "smooth", block: "start" })); return () => cancelAnimationFrame(frame); }, [showHebeiDrilldown]); const activateTab = (tab: Tab) => { setActiveId(tab.id); setHoveredName(null); }; const selectMappedRegion = (name: string) => { setSelectedMapName(name); setSelectedName(name); }; return (
Terra ProduceGLOBAL ORIGIN ATLAS
本地地图与 JSON 数据

GLOBAL PRODUCE MAP

全球主要产物地图

按地区浏览国家地图,高亮到省、州、都道府县等一级行政区,点击查看该产区的代表作物。
地区入口{tabs.length}全球区域
示例产区{tabs.reduce((sum, item) => sum + item.regions.length, 0)}一级行政区
当前作物{cropNames.length}{active.country}
{tabs.map((tab) => )}
{active.iso} · ADMIN LEVEL 1

{active.label} · {active.country}

{active.description}

主要产区其他行政区
{loading &&

正在加载行政区地图…

} {!loading && !geoData &&
!

地图数据加载失败,请刷新重试。

} {geoData && {active.id === "usa" && } {mapShapes.map(({ d, name, key }) => { const marked = active.regions.some((region) => region.name === name); const isSelected = selectedMapName === name; return selectMappedRegion(name)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") selectMappedRegion(name); }} onMouseEnter={() => setHoveredName(name)} onMouseLeave={() => setHoveredName(null)} aria-pressed={isSelected} aria-label={`选择${name}`} />; })}{selectedShape && } } {hoveredName &&
{active.regions.find((item) => item.name === hoveredName)?.localName ?? localRegionName(active.id, hoveredName)}{hoveredName}{active.regions.some((item) => item.name === hoveredName) ? "点击高亮并查看主要产物" : "点击高亮,作物资料待补充"}
}
点击彩色行政区查看主要作物
{showHebeiDrilldown &&

PROVINCE DRILLDOWN · ADM2

河北省 · 市级行政单位

从全国一级行政区继续下钻,点击市级边界查看当地代表产物。
HEBEI · ADMIN LEVEL 2

河北省市级地图

共 11 个地级市,保持与国家地图一致的点击与高亮方式。

已选城市其他城市
{hebeiGeoData ? {hebeiShapes.map(({ d, name, key }) => { const isSelected = selectedHebeiCity === name; return setSelectedHebeiCity(name)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") setSelectedHebeiCity(name); }} onMouseEnter={() => setHoveredHebeiCity(name)} onMouseLeave={() => setHoveredHebeiCity(null)} aria-pressed={isSelected} aria-label={`选择${hebeiCities.find((city) => city.name === name)?.localName ?? name}`} />; })}{selectedCityShape && } :

正在加载河北省市级地图…

} {hoveredHebeiCity &&
{hebeiCities.find((city) => city.name === hoveredHebeiCity)?.localName ?? hoveredHebeiCity}{hoveredHebeiCity}点击高亮并查看市级产物
}
点击市级区域查看主要产物
{hebeiCities.map((city, index) => )}
}

PRIMARY ORIGINS

{active.country}重点产区

{active.regions.length} 个示例行政区 · 点击可同步高亮地图
{active.regions.map((region, index) => )}
); } function cropIcon(crop: string) { if (/苹果|梨|樱桃|葡萄|芒果|橙|柑|莓|瓜|果|香蕉|菠萝|荔枝/.test(crop)) return "果"; if (/小麦|大麦|玉米|水稻|燕麦|荞麦|大豆|油菜|豆|扁豆|豌豆/.test(crop)) return "谷"; if (/茶|咖啡|可可|啤酒花|葡萄酒/.test(crop)) return "饮"; if (/奶|乳|猪|水产/.test(crop)) return "鲜"; return "植"; } function localRegionName(tabId: string, name: string) { return regionTranslations[tabId]?.[name] ?? name; } function projectMapFeatures(features: Feature[], extent: [[number, number], [number, number]], keyPrefix = "map"): MapShape[] { if (!features.length) return []; // geoBoundaries uses planar GeoJSON rings. The reflected identity projection // preserves the real outlines without treating the ring winding as a globe. const collection: FeatureCollection = { type: "FeatureCollection", features }; const projection = geoIdentity().reflectY(true).fitExtent(extent, collection as never); const path = geoPath(projection); return features.map((feature, index) => ({ feature, d: path(feature as never) ?? "", centroid: path.centroid(feature as never) as [number, number], name: feature.properties.shapeName ?? "未命名区域", key: `${keyPrefix}-${feature.properties.shapeName ?? index}` })); } function buildMapShapes(data: FeatureCollection, tabId: string): MapShape[] { if (tabId === "russia") return projectRussiaFeatures(data.features); if (tabId !== "usa") return projectMapFeatures(data.features, [[44, 42], [956, 598]]); const byName = (names: string[]) => data.features.filter((feature) => names.includes(feature.properties.shapeName ?? "")); const detached = new Set([ "Alaska", "Hawaii", "Puerto Rico", "United States Virgin Islands", "American Samoa", "Guam", "Commonwealth of the Northern Mariana Islands" ]); const mainland = data.features.filter((feature) => !detached.has(feature.properties.shapeName ?? "")); // The contiguous states fill the canvas. Detached states and territories are // placed in compact insets so they no longer shrink the main US silhouette. return [ ...projectMapFeatures(mainland, [[34, 48], [966, 520]], "usa-main"), ...projectMapFeatures(byName(["Alaska"]), [[52, 472], [260, 610]], "usa-alaska"), ...projectMapFeatures(byName(["Hawaii"]), [[290, 532], [416, 610]], "usa-hawaii"), ...projectMapFeatures(byName(["Puerto Rico", "United States Virgin Islands"]), [[448, 570], [548, 610]], "usa-caribbean"), ...projectMapFeatures(byName(["American Samoa"]), [[588, 574], [650, 610]], "usa-samoa"), ...projectMapFeatures(byName(["Guam"]), [[680, 574], [718, 610]], "usa-guam"), ...projectMapFeatures(byName(["Commonwealth of the Northern Mariana Islands"]), [[750, 570], [810, 610]], "usa-mariana") ]; } function projectRussiaFeatures(features: Feature[]): MapShape[] { const projectedFeatures = features.map(reverseSphericalRings); const collection: FeatureCollection = { type: "FeatureCollection", features: projectedFeatures }; const projection = geoConicConformal().parallels([50, 70]).rotate([-100, 0]).fitExtent([[34, 42], [966, 598]], collection as never); const path = geoPath(projection); return projectedFeatures.map((feature, index) => ({ feature, d: path(feature as never) ?? "", centroid: path.centroid(feature as never) as [number, number], name: feature.properties.shapeName ?? "未命名区域", key: `russia-${feature.properties.shapeName ?? index}` })); } function reverseSphericalRings(feature: Feature): Feature { const geometry = feature.geometry as { type?: string; coordinates?: unknown }; if (geometry.type === "Polygon") { const coordinates = geometry.coordinates as number[][][]; return { ...feature, geometry: { ...geometry, coordinates: coordinates.map((ring) => [...ring].reverse()) } }; } if (geometry.type === "MultiPolygon") { const coordinates = geometry.coordinates as number[][][][]; return { ...feature, geometry: { ...geometry, coordinates: coordinates.map((polygon) => polygon.map((ring) => [...ring].reverse())) } }; } return feature; }