This repository has been archived on 2026-08-27. You can view files and clone it, but cannot push or open issues or pull requests.
Files
RECS-WEB-MODLE-REACT/app/produce-map/page.tsx
2026-08-27 20:15:24 +08:00

267 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<string, Record<string, string>> = {
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<FeatureCollection | null>(null);
const [selectedName, setSelectedName] = useState(tabs[0].regions[0].name);
const [selectedMapName, setSelectedMapName] = useState(tabs[0].regions[0].name);
const [hoveredName, setHoveredName] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [hebeiGeoData, setHebeiGeoData] = useState<FeatureCollection | null>(null);
const [selectedHebeiCity, setSelectedHebeiCity] = useState(hebeiCities[0].name);
const [hoveredHebeiCity, setHoveredHebeiCity] = useState<string | null>(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 (
<div className={styles.page} style={{ "--accent": active.accent } as CSSProperties}>
<header className={styles.topbar}>
<Link href="/" className={styles.brand}><span></span><div><strong>Terra Produce</strong><small>GLOBAL ORIGIN ATLAS</small></div></Link>
<nav><Link href="/"></Link><a href="#atlas"></a><a href="#regions"></a></nav>
<div className={styles.status}><i /> JSON </div>
</header>
<main>
<section className={styles.hero}>
<div><p>GLOBAL PRODUCE MAP</p><h1></h1><span></span></div>
<div className={styles.metrics}><article><small></small><strong>{tabs.length}</strong><span></span></article><article><small></small><strong>{tabs.reduce((sum, item) => sum + item.regions.length, 0)}</strong><span></span></article><article><small></small><strong>{cropNames.length}</strong><span>{active.country}</span></article></div>
</section>
<section className={styles.atlas} id="atlas">
<div className={styles.tabs} role="tablist" aria-label="地区地图">
{tabs.map((tab) => <button role="tab" aria-selected={active.id === tab.id} key={tab.id} className={active.id === tab.id ? styles.activeTab : ""} onClick={() => activateTab(tab)}>{tab.label}<small>{tab.country}</small></button>)}
</div>
<div className={styles.mapLayout}>
<section className={styles.mapPanel}>
<header><div><small>{active.iso} · ADMIN LEVEL 1</small><h2>{active.label} · {active.country}</h2><p>{active.description}</p></div><div className={styles.legend}><span><i /></span><span><i /></span></div></header>
<div className={styles.mapCanvas}>
{loading && <div className={styles.mapLoading}><span /><p></p></div>}
{!loading && !geoData && <div className={styles.mapLoading}><b>!</b><p></p></div>}
{geoData && <svg viewBox="0 0 1000 640" role="img" aria-label={`${active.country}一级行政区产物地图`}>
<defs><filter id="mapGlow"><feGaussianBlur stdDeviation="7" result="blur" /><feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge></filter></defs>
{active.id === "usa" && <g className={styles.usaInsets} aria-hidden="true">
<rect x="44" y="462" width="224" height="156" rx="10" /><text x="58" y="482"></text>
<rect x="282" y="520" width="142" height="98" rx="10" /><text x="296" y="540"></text>
<rect x="440" y="546" width="116" height="72" rx="10" /><text x="452" y="566"></text>
</g>}
<g className={styles.mapViewport}>{mapShapes.map(({ d, name, key }) => {
const marked = active.regions.some((region) => region.name === name);
const isSelected = selectedMapName === name;
return <path key={key} d={d} tabIndex={0} role="button" className={`${styles.mapRegion} ${marked ? styles.markedRegion : ""} ${isSelected ? styles.selectedRegion : ""}`} onClick={() => 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 && <g className={styles.mapLabel} transform={`translate(${selectedShape.centroid[0]} ${selectedShape.centroid[1]})`} aria-hidden="true"><circle r="7" /><path d="M0 7 L0 22" /><rect x="-38" y="22" width="76" height="25" rx="7" /><text y="39">{selectedDisplayName}</text></g>}</g>
</svg>}
{hoveredName && <div className={styles.mapTooltip}><strong>{active.regions.find((item) => item.name === hoveredName)?.localName ?? localRegionName(active.id, hoveredName)}</strong><small>{hoveredName}</small><span>{active.regions.some((item) => item.name === hoveredName) ? "点击高亮并查看主要产物" : "点击高亮,作物资料待补充"}</span></div>}
<div className={styles.mapHint}></div>
</div>
</section>
<aside className={styles.detailPanel}>
<header><small>SELECTED REGION</small><span>{active.iso}</span><h2>{selectedDisplayName}</h2><p>{selectedMapName}</p></header>
<div className={styles.detailBody}>
{selected ? <section><span></span><div className={styles.cropGrid}>{selected.crops.map((crop, index) => <article key={crop}><b>{cropIcon(crop)}</b><div><strong>{crop}</strong><small> {String(index + 1).padStart(2, "0")}</small></div></article>)}</div></section> : <section className={styles.emptyProduce}><b></b><span></span><p> JSON </p></section>}
<dl><div><dt></dt><dd>{selected?.season ?? "待补充"}</dd></div><div><dt></dt><dd></dd></div><div><dt></dt><dd>{active.country}</dd></div></dl>
<section className={styles.originNote}><span></span><p>{selected?.summary ?? `${selectedMapName}当前暂无主要产物说明,已保留完整点击与高亮交互。`}</p></section>
</div>
<footer><button onClick={() => document.getElementById("regions")?.scrollIntoView({ behavior: "smooth" })}> </button></footer>
</aside>
</div>
</section>
{showHebeiDrilldown && <section className={styles.drilldownSection} id="province-detail">
<div className={styles.drilldownHeader}>
<div><p>PROVINCE DRILLDOWN · ADM2</p><h2> · </h2><span></span></div>
<button onClick={() => document.getElementById("atlas")?.scrollIntoView({ behavior: "smooth" })}> </button>
</div>
<div className={styles.mapLayout}>
<section className={styles.mapPanel}>
<header><div><small>HEBEI · ADMIN LEVEL 2</small><h2></h2><p> 11 </p></div><div className={styles.legend}><span><i /></span><span><i /></span></div></header>
<div className={styles.mapCanvas}>
{hebeiGeoData ? <svg viewBox="0 0 1000 640" role="img" aria-label="河北省市级行政区产物地图">
<defs><filter id="cityGlow"><feGaussianBlur stdDeviation="7" result="blur" /><feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge></filter></defs>
<g className={styles.mapViewport}>{hebeiShapes.map(({ d, name, key }) => {
const isSelected = selectedHebeiCity === name;
return <path key={key} d={d} tabIndex={0} role="button" className={`${styles.mapRegion} ${styles.markedRegion} ${isSelected ? styles.selectedRegion : ""}`} onClick={() => 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 && <g className={styles.mapLabel} transform={`translate(${selectedCityShape.centroid[0]} ${selectedCityShape.centroid[1]})`} aria-hidden="true"><circle r="7" /><path d="M0 7 L0 22" /><rect x="-42" y="22" width="84" height="25" rx="7" /><text y="39">{selectedCity.localName}</text></g>}</g>
</svg> : <div className={styles.mapLoading}><span /><p></p></div>}
{hoveredHebeiCity && <div className={styles.mapTooltip}><strong>{hebeiCities.find((city) => city.name === hoveredHebeiCity)?.localName ?? hoveredHebeiCity}</strong><small>{hoveredHebeiCity}</small><span></span></div>}
<div className={styles.mapHint}></div>
</div>
</section>
<aside className={styles.detailPanel}>
<header><small>SELECTED CITY</small><span>HEB</span><h2>{selectedCity.localName}</h2><p>{selectedCity.name}</p></header>
<div className={styles.detailBody}>
<section><span></span><div className={styles.cropGrid}>{selectedCity.crops.map((crop, index) => <article key={crop}><b>{cropIcon(crop)}</b><div><strong>{crop}</strong><small> {String(index + 1).padStart(2, "0")}</small></div></article>)}</div></section>
<dl><div><dt></dt><dd>{selectedCity.season}</dd></div><div><dt></dt><dd></dd></div><div><dt></dt><dd> · </dd></div></dl>
<section className={styles.originNote}><span></span><p>{selectedCity.summary}</p></section>
</div>
</aside>
</div>
<div className={styles.cityList}>{hebeiCities.map((city, index) => <button key={city.name} className={selectedHebeiCity === city.name ? styles.activeCity : ""} onClick={() => setSelectedHebeiCity(city.name)}><span>{String(index + 1).padStart(2, "0")}</span><div><small>{city.name}</small><strong>{city.localName}</strong></div><b>{city.crops[0]} · {city.crops[1]}</b></button>)}</div>
</section>}
<section className={styles.regionSection} id="regions">
<div className={styles.sectionTitle}><div><p>PRIMARY ORIGINS</p><h2>{active.country}</h2></div><span>{active.regions.length} · </span></div>
<div className={styles.regionGrid}>{active.regions.map((region, index) => <button key={region.name} className={selectedMapName === region.name ? styles.activeRegionCard : ""} onClick={() => { setSelectedName(region.name); setSelectedMapName(region.name); document.getElementById("atlas")?.scrollIntoView({ behavior: "smooth" }); }}><span>{String(index + 1).padStart(2, "0")}</span><div><small>{region.name}</small><strong>{region.localName}</strong><p>{region.summary}</p><footer>{region.crops.map((crop) => <i key={crop}>{crop}</i>)}</footer></div><b> </b></button>)}</div>
</section>
</main>
</div>
);
}
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;
}