This commit is contained in:
2026-07-18 12:40:30 +08:00 Unverified
parent b20f5d736d
commit 03579c51d8
7 changed files with 916 additions and 0 deletions
+102
View File
@@ -10,10 +10,15 @@
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.9", "@astrojs/check": "^0.9.9",
"astro": "^7.1.0", "astro": "^7.1.0",
"cn-atlas": "^0.1.2",
"d3-geo": "^3.1.1",
"topojson-client": "^3.1.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/d3-geo": "^3.1.0",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"@types/topojson-client": "^3.1.5",
"tsx": "^4.23.1" "tsx": "^4.23.1"
} }
}, },
@@ -2086,6 +2091,16 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2101,6 +2116,13 @@
"@types/estree": "*" "@types/estree": "*"
} }
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": { "node_modules/@types/hast": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
@@ -2138,6 +2160,27 @@
"undici-types": "~8.3.0" "undici-types": "~8.3.0"
} }
}, },
"node_modules/@types/topojson-client": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*",
"@types/topojson-specification": "*"
}
},
"node_modules/@types/topojson-specification": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -2553,6 +2596,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cn-atlas": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/cn-atlas/-/cn-atlas-0.1.2.tgz",
"integrity": "sha512-boViEllOaFIANaZ4ujemMIR6ryDm1619qcdp20yEd5sAR/SF2uUAZLcV9IY7PlseaTCw4muSLSOPo1valLpOxw==",
"license": "ISC"
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2701,6 +2750,30 @@
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/defu": { "node_modules/defu": {
"version": "6.1.7", "version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
@@ -3223,6 +3296,15 @@
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/iron-webcrypto": { "node_modules/iron-webcrypto": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
@@ -4393,6 +4475,26 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"license": "ISC",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
},
"node_modules/topojson-client/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/trim-lines": { "node_modules/trim-lines": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+5
View File
@@ -13,10 +13,15 @@
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.9", "@astrojs/check": "^0.9.9",
"astro": "^7.1.0", "astro": "^7.1.0",
"cn-atlas": "^0.1.2",
"d3-geo": "^3.1.1",
"topojson-client": "^3.1.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/d3-geo": "^3.1.0",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"@types/topojson-client": "^3.1.5",
"tsx": "^4.23.1" "tsx": "^4.23.1"
} }
} }
+68
View File
@@ -0,0 +1,68 @@
import { people } from "./people";
export interface ClassMapEntry {
personSlug: string;
city: string;
province: string;
/** 城市中心点经度,仅用于城市级地图投影。 */
longitude: number;
/** 城市中心点纬度,仅用于城市级地图投影。 */
latitude: number;
}
/**
* 只有明确同意在班级地图公开的同学,才应出现在这个列表中。
* 地图仅展示城市中心点,不采集或保存个人精确位置。
*/
export const classMapEntries: ClassMapEntry[] = [
{ personSlug: "student-a", city: "北京", province: "北京市", longitude: 116.4074, latitude: 39.9042 },
{ personSlug: "biss", city: "高平", province: "山西省", longitude: 112.9239, latitude: 35.798 }
];
const peopleBySlug = new Map(people.map((person) => [person.slug, person]));
const toMapPerson = (person: (typeof people)[number]) => ({
slug: person.slug,
initial: person.initial,
name: person.name,
photo: person.photo,
direction: person.direction
});
export const mapCities = Array.from(
classMapEntries.reduce((cities, entry) => {
const person = peopleBySlug.get(entry.personSlug);
if (!person) return cities;
const key = `${entry.province}-${entry.city}`;
const existing = cities.get(key);
if (existing) {
existing.people.push(toMapPerson(person));
} else {
cities.set(key, {
key,
city: entry.city,
province: entry.province,
longitude: entry.longitude,
latitude: entry.latitude,
people: [toMapPerson(person)]
});
}
return cities;
}, new Map<string, {
key: string;
city: string;
province: string;
longitude: number;
latitude: number;
people: ReturnType<typeof toMapPerson>[];
}>()).values()
).sort((a, b) => b.people.length - a.people.length || a.city.localeCompare(b.city, "zh-CN"));
export const mapStats = {
cityCount: mapCities.length,
publicPeopleCount: mapCities.reduce((total, city) => total + city.people.length, 0),
provinceCount: new Set(mapCities.map((city) => city.province)).size
};
+1
View File
@@ -33,6 +33,7 @@ export const navItems = [
{ label: "照片墙", href: "/gallery/" }, { label: "照片墙", href: "/gallery/" },
{ label: "青春刊物", href: "/articles/" }, { label: "青春刊物", href: "/articles/" },
{ label: "如今的我们", href: "/people/" }, { label: "如今的我们", href: "/people/" },
{ label: "班级地图", href: "/map/" },
{ label: "综合试卷", href: "/exam/" }, { label: "综合试卷", href: "/exam/" },
{ label: "留言墙", href: "/messages/" }, { label: "留言墙", href: "/messages/" },
{ label: "搜索", href: "/search/" } { label: "搜索", href: "/search/" }
+236
View File
@@ -0,0 +1,236 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import { mapCities, mapStats } from "../data/class-map";
import { site } from "../data/site";
import atlas from "cn-atlas/cn-atlas.json";
import { geoMercator, geoPath } from "d3-geo";
import { feature } from "topojson-client";
import type { GeometryCollection, Topology } from "topojson-specification";
const firstCity = mapCities[0];
const mapWidth = 900;
const mapHeight = 620;
const topology = atlas as unknown as Topology;
const provinceObject = topology.objects.provinces as GeometryCollection;
const provinceCollection = feature(topology, provinceObject);
const projection = geoMercator().fitExtent([[48, 42], [mapWidth - 48, mapHeight - 34]], provinceCollection);
const path = geoPath(projection);
const activeProvinces = new Set(mapCities.map((city) => city.province));
const provincePaths = provinceCollection.features.map((province) => {
const properties = (province.properties ?? {}) as Record<string, unknown>;
return {
name: String(properties["地名"] ?? properties.name ?? ""),
d: path(province) ?? ""
};
});
const layoutCityMarkers = () => {
const markers = mapCities.map((city) => {
const [anchorX, anchorY] = projection([city.longitude, city.latitude]) ?? [0, 0];
return { ...city, anchorX, anchorY, x: anchorX, y: anchorY };
});
const minDistance = markers.length > 18 ? 36 : 44;
for (let iteration = 0; iteration < 100; iteration += 1) {
for (let leftIndex = 0; leftIndex < markers.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < markers.length; rightIndex += 1) {
const left = markers[leftIndex];
const right = markers[rightIndex];
let deltaX = right.x - left.x;
let deltaY = right.y - left.y;
let distance = Math.hypot(deltaX, deltaY);
if (distance >= minDistance) continue;
if (distance < 0.01) {
const angle = ((leftIndex * 137.5 + rightIndex * 47) * Math.PI) / 180;
deltaX = Math.cos(angle);
deltaY = Math.sin(angle);
distance = 1;
}
const push = (minDistance - distance) * 0.52;
const normalX = deltaX / distance;
const normalY = deltaY / distance;
left.x -= normalX * push;
left.y -= normalY * push;
right.x += normalX * push;
right.y += normalY * push;
}
}
markers.forEach((marker) => {
marker.x += (marker.anchorX - marker.x) * 0.075;
marker.y += (marker.anchorY - marker.y) * 0.075;
marker.x = Math.min(mapWidth - 38, Math.max(38, marker.x));
marker.y = Math.min(mapHeight - 38, Math.max(38, marker.y));
});
}
return markers;
};
const displayCities = layoutCityMarkers();
const isDenseMap = displayCities.length > 12;
---
<BaseLayout title={`班级地图 · ${site.className}`}>
<main class="page-main map-page">
<section class="page-hero map-hero">
<div class="section-inner">
<a class="back-link" href="/">返回首页</a>
<p class="eyebrow">Where We Are</p>
<h1>从同一间教室,走向不同的城市</h1>
<p>这里只记录城市,不显示学校校区、住址或实时位置。点击一座城市,看看愿意公开近况的同学。</p>
</div>
</section>
<section class="map-section">
<div class="section-inner">
<div class="map-summary" aria-label="班级地图概览">
<div><strong>{mapStats.cityCount}</strong><span>座城市</span></div>
<div><strong>{mapStats.provinceCount}</strong><span>个省级地区</span></div>
<div><strong>{mapStats.publicPeopleCount}</strong><span>位同学自愿公开</span></div>
<p><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> 城市级展示 · 自愿公开 · 随时可撤回</p>
</div>
<div class="class-map-shell">
<div class="class-map" aria-label="班级成员城市分布示意图">
<div class="map-caption">
<span>我们的足迹</span>
<small>真实省界 · 城市中心点投影</small>
</div>
<svg class:list={["map-geo", isDenseMap && "is-dense"]} viewBox={`0 0 ${mapWidth} ${mapHeight}`} role="img" aria-label="中国省级行政区与班级城市分布地图">
<defs>
<linearGradient id="province-fill" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fffdf7" />
<stop offset="1" stop-color="#edf3ea" />
</linearGradient>
<filter id="province-shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="8" stdDeviation="9" flood-color="#263d37" flood-opacity="0.10" />
</filter>
</defs>
<g class="province-layer" filter="url(#province-shadow)">
{
provincePaths.map((province) => (
<path class:list={["map-province", activeProvinces.has(province.name) && "has-classmate"]} d={province.d}>
<title>{province.name}</title>
</path>
))
}
</g>
<g class="city-marker-layer">
{
displayCities.map((city, index) => (
<g
class:list={["city-marker", index === 0 && "is-active"]}
transform={`translate(${city.x} ${city.y})`}
style={`--city-delay: ${Math.min(index * 55, 660)}ms`}
data-city-key={city.key}
role="button"
tabindex="0"
aria-label={`${city.province}${city.city}${city.people.length} 位同学`}
aria-pressed={index === 0 ? "true" : "false"}
>
<line
class="city-marker-leader"
x1="0"
y1="0"
x2={city.anchorX - city.x}
y2={city.anchorY - city.y}
/>
<circle class="city-marker-halo" r="22" />
<circle class="city-marker-dot" r="16" />
<text class="city-marker-count" text-anchor="middle" y="5">{city.people.length}</text>
<text class="city-marker-name" text-anchor="middle" y="35">{city.city}</text>
</g>
))
}
</g>
</svg>
<p class="map-source">省级边界数据:cn-atlas2023</p>
</div>
<aside class="city-panel" aria-live="polite">
<div class="city-panel-heading">
<p class="eyebrow">City</p>
<h2 data-city-title>{firstCity?.city ?? "暂无城市"}</h2>
<p data-city-subtitle>{firstCity ? `${firstCity.province} · ${firstCity.people.length} 位同学` : "等待同学自愿加入"}</p>
</div>
<div class="city-people" data-city-people>
{
firstCity?.people.map((person) => (
<a class="map-person" href={`/people/${person.slug}/`}>
<span class="map-person-avatar" style={person.photo ? `--avatar-image: url("${person.photo}")` : ""}>
{person.initial}
</span>
<span>
<strong>{person.name}</strong>
<small>{person.direction}</small>
</span>
<i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
</a>
))
}
</div>
<div class="map-privacy-note">
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>
<p><strong>关于位置</strong><span>不采集精确地址与实时定位。名单仅包含明确选择公开的同学。</span></p>
</div>
</aside>
</div>
<noscript>
<div class="city-directory">
{mapCities.map((city) => <p><strong>{city.city}</strong>{city.people.map((person) => person.name).join("、")}</p>)}
</div>
</noscript>
</div>
</section>
</main>
<script is:inline define:vars={{ mapCities }}>
const cityTitle = document.querySelector("[data-city-title]");
const citySubtitle = document.querySelector("[data-city-subtitle]");
const cityPeople = document.querySelector("[data-city-people]");
const markers = document.querySelectorAll("[data-city-key]");
const escapeHtml = (value) => String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const showCity = (key) => {
const city = mapCities.find((item) => item.key === key);
if (!city || !cityTitle || !citySubtitle || !cityPeople) return;
cityTitle.textContent = city.city;
citySubtitle.textContent = `${city.province} · ${city.people.length} 位同学`;
cityPeople.innerHTML = city.people.map((person) => `
<a class="map-person" href="/people/${encodeURIComponent(person.slug)}/">
<span class="map-person-avatar"${person.photo ? ` style="--avatar-image: url(&quot;${escapeHtml(person.photo)}&quot;)"` : ""}>${escapeHtml(person.initial)}</span>
<span><strong>${escapeHtml(person.name)}</strong><small>${escapeHtml(person.direction)}</small></span>
<i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
</a>
`).join("");
markers.forEach((marker) => {
const isActive = marker.getAttribute("data-city-key") === key;
marker.classList.toggle("is-active", isActive);
marker.setAttribute("aria-pressed", String(isActive));
});
};
markers.forEach((marker) => {
marker.addEventListener("click", () => showCity(marker.getAttribute("data-city-key")));
marker.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
showCity(marker.getAttribute("data-city-key"));
});
});
</script>
</BaseLayout>
+2
View File
@@ -12,6 +12,7 @@ import { site } from "../data/site";
<p class="eyebrow">Classmates</p> <p class="eyebrow">Classmates</p>
<h1>{peopleIntro.title}</h1> <h1>{peopleIntro.title}</h1>
<p>{peopleIntro.text}</p> <p>{peopleIntro.text}</p>
<a class="hero-inline-link" href="/map/"><i class="fa-solid fa-map-location-dot" aria-hidden="true"></i> 查看班级地图</a>
</div> </div>
</section> </section>
@@ -32,6 +33,7 @@ import { site } from "../data/site";
<li><strong>照片:</strong><code>photo</code> 可留空;如需放照片,将图片放入 <code>public</code> 后填写以 <code>/</code> 开头的路径。</li> <li><strong>照片:</strong><code>photo</code> 可留空;如需放照片,将图片放入 <code>public</code> 后填写以 <code>/</code> 开头的路径。</li>
<li><strong>关键词:</strong><code>keywords</code> 是数组,建议保留 1 到 3 个短词,列表页和个人页都会显示。</li> <li><strong>关键词:</strong><code>keywords</code> 是数组,建议保留 1 到 3 个短词,列表页和个人页都会显示。</li>
<li><strong>个人页:</strong><code>currentStatus</code>、<code>highlight</code>、<code>toPastSelf</code>、<code>favoriteMemory</code> 和 <code>messageToClass</code> 会生成详情内容。</li> <li><strong>个人页:</strong><code>currentStatus</code>、<code>highlight</code>、<code>toPastSelf</code>、<code>favoriteMemory</code> 和 <code>messageToClass</code> 会生成详情内容。</li>
<li><strong>班级地图:</strong>只有明确同意公开城市的同学才添加到 <code>src/data/class-map.ts</code>;不要填写地址、学校校区或实时位置。</li>
</ol> </ol>
</aside> </aside>
+502
View File
@@ -755,6 +755,19 @@ h2 {
font-size: clamp(17px, 2vw, 21px); font-size: clamp(17px, 2vw, 21px);
} }
.hero-inline-link {
display: inline-flex;
align-items: center;
gap: 9px;
margin-top: 24px;
color: #f3cf8b;
font-weight: 800;
}
.hero-inline-link:hover {
color: #fffdf7;
}
.back-link, .back-link,
.section-link { .section-link {
display: inline-flex; display: inline-flex;
@@ -850,6 +863,495 @@ h2 {
gap: 16px; gap: 16px;
} }
.map-hero {
position: relative;
overflow: hidden;
background:
radial-gradient(circle at 82% 34%, rgba(232, 168, 76, 0.28), transparent 23%),
linear-gradient(135deg, #1d2d2a, #355f52 58%, #456f94);
}
.map-hero::after {
content: "";
position: absolute;
right: clamp(-80px, 2vw, 24px);
bottom: -110px;
width: clamp(280px, 34vw, 520px);
aspect-ratio: 1;
border: 1px solid rgba(255, 253, 247, 0.12);
border-radius: 50%;
box-shadow:
0 0 0 48px rgba(255, 253, 247, 0.035),
0 0 0 96px rgba(255, 253, 247, 0.02);
pointer-events: none;
}
.map-section {
padding-top: clamp(36px, 5vw, 68px);
background:
linear-gradient(rgba(55, 109, 90, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(55, 109, 90, 0.035) 1px, transparent 1px),
var(--paper);
background-size: 32px 32px;
}
.map-section > .section-inner {
width: min(1420px, 100%);
}
.map-summary {
display: grid;
grid-template-columns: repeat(3, minmax(110px, 0.55fr)) minmax(260px, 1.7fr);
align-items: stretch;
margin-bottom: 18px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 253, 247, 0.94);
box-shadow: 0 10px 34px rgba(39, 55, 52, 0.08);
}
.map-summary > div {
display: grid;
align-content: center;
min-height: 90px;
padding: 18px 22px;
border-right: 1px solid var(--line);
}
.map-summary strong {
color: var(--green);
font-size: 30px;
line-height: 1;
}
.map-summary span {
margin-top: 8px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.map-summary > p {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
padding: 20px 24px;
color: var(--muted);
font-size: 13px;
}
.map-summary > p i {
color: var(--green);
font-size: 18px;
}
.class-map-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 360px);
min-height: 720px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 14px;
background: #fff;
box-shadow: var(--shadow);
}
.class-map {
position: relative;
min-height: 720px;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(circle at 67% 22%, rgba(232, 168, 76, 0.14), transparent 24%),
linear-gradient(rgba(55, 109, 90, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(55, 109, 90, 0.025) 1px, transparent 1px),
linear-gradient(145deg, #edf4ec, #f9f6e9 54%, #edf3f5);
background-size: auto, 28px 28px, 28px 28px, auto;
}
.map-caption {
position: absolute;
top: 28px;
left: 30px;
z-index: 4;
display: grid;
}
.map-caption span {
color: var(--ink);
font-size: 20px;
font-weight: 900;
}
.map-caption small {
margin-top: 2px;
color: var(--muted);
}
.map-geo {
position: absolute;
inset: 60px 18px 24px;
width: calc(100% - 36px);
height: calc(100% - 84px);
overflow: visible;
}
.map-province {
fill: url("#province-fill");
stroke: rgba(55, 109, 90, 0.42);
stroke-width: 1.05;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
transition: fill 180ms ease, stroke 180ms ease;
}
.map-province:hover {
fill: #e5eee2;
stroke: rgba(55, 109, 90, 0.72);
}
.map-province.has-classmate {
fill: #f3e4bd;
stroke: rgba(201, 100, 82, 0.72);
}
.map-source {
position: absolute;
left: 28px;
bottom: 18px;
z-index: 4;
margin: 0;
color: rgba(98, 112, 111, 0.78);
font-size: 11px;
}
.city-marker {
cursor: pointer;
opacity: 0;
outline: none;
animation: city-arrive 420ms ease forwards;
animation-delay: var(--city-delay);
}
@keyframes city-arrive {
to {
opacity: 1;
}
}
.city-marker-leader {
stroke: rgba(55, 109, 90, 0.48);
stroke-width: 1.25;
stroke-dasharray: 3 3;
vector-effect: non-scaling-stroke;
}
.city-marker-halo {
fill: rgba(255, 253, 247, 0.76);
stroke: rgba(55, 109, 90, 0.26);
stroke-width: 1;
transition: fill 160ms ease, stroke 160ms ease, r 160ms ease;
}
.city-marker-dot {
fill: var(--green);
stroke: rgba(255, 253, 247, 0.95);
stroke-width: 3;
filter: drop-shadow(0 6px 8px rgba(39, 55, 52, 0.26));
transform-box: fill-box;
transform-origin: center;
transition: fill 160ms ease, transform 160ms ease;
}
.city-marker-count {
fill: #fff;
font-size: 13px;
font-weight: 900;
pointer-events: none;
}
.city-marker-name {
fill: var(--ink);
stroke: rgba(255, 253, 247, 0.96);
stroke-width: 6px;
stroke-linejoin: round;
paint-order: stroke fill;
font-size: 12px;
font-weight: 800;
pointer-events: none;
transition: opacity 160ms ease, transform 160ms ease;
}
.city-marker:hover .city-marker-dot,
.city-marker:focus-visible .city-marker-dot,
.city-marker.is-active .city-marker-dot {
fill: var(--coral);
transform: scale(1.12);
}
.city-marker:hover .city-marker-halo,
.city-marker:focus-visible .city-marker-halo,
.city-marker.is-active .city-marker-halo {
fill: rgba(255, 253, 247, 0.96);
stroke: rgba(201, 100, 82, 0.58);
r: 24px;
}
.map-geo.is-dense .city-marker-name {
opacity: 0;
transform: translateY(-4px);
}
.map-geo.is-dense .city-marker-dot {
r: 12px;
}
.map-geo.is-dense .city-marker-halo {
r: 17px;
}
.map-geo.is-dense .city-marker-count {
font-size: 10px;
transform: translateY(-1px);
}
.map-geo.is-dense .city-marker:hover .city-marker-name,
.map-geo.is-dense .city-marker:focus-visible .city-marker-name,
.map-geo.is-dense .city-marker.is-active .city-marker-name {
opacity: 1;
transform: translateY(0);
}
.city-marker:focus-visible .city-marker-halo {
stroke: var(--blue);
stroke-width: 3;
}
.city-panel {
display: flex;
flex-direction: column;
min-width: 0;
padding: clamp(24px, 4vw, 38px);
border-left: 1px solid var(--line);
background: rgba(255, 253, 247, 0.96);
}
.city-panel-heading {
padding-bottom: 22px;
border-bottom: 1px solid var(--line);
}
.city-panel-heading .eyebrow {
margin-bottom: 8px;
color: var(--coral);
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.city-panel-heading h2 {
font-size: clamp(34px, 4vw, 48px);
}
.city-panel-heading > p:last-child {
margin: 8px 0 0;
color: var(--muted);
font-size: 14px;
}
.city-people {
display: grid;
gap: 10px;
padding: 22px 0;
max-height: 480px;
overflow-y: auto;
scrollbar-width: thin;
}
.map-person {
display: grid;
grid-template-columns: 46px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 11px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
transition: border-color 160ms ease, transform 160ms ease;
}
.map-person:hover,
.map-person:focus-visible {
transform: translateX(3px);
border-color: rgba(55, 109, 90, 0.42);
}
.map-person-avatar {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: 50%;
background:
linear-gradient(rgba(31, 43, 42, 0.12), rgba(31, 43, 42, 0.12)),
var(--avatar-image, linear-gradient(135deg, var(--green), var(--sun)));
background-position: center;
background-size: cover;
color: #fff;
font-size: 12px;
font-weight: 900;
}
.map-person > span:nth-child(2) {
min-width: 0;
display: grid;
}
.map-person strong,
.map-person small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.map-person small {
color: var(--muted);
}
.map-person > i {
color: var(--green);
font-size: 12px;
}
.map-privacy-note {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 11px;
align-items: start;
margin-top: auto;
padding: 15px;
border-radius: 10px;
background: var(--soft);
color: var(--muted);
}
.map-privacy-note > i {
margin-top: 4px;
color: var(--green);
}
.map-privacy-note p,
.map-privacy-note span {
margin: 0;
}
.map-privacy-note p {
display: grid;
gap: 2px;
font-size: 12px;
}
.map-privacy-note strong {
color: var(--ink);
font-size: 13px;
}
.city-directory {
margin-top: 18px;
padding: 18px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
}
@media (max-width: 900px) {
.map-summary {
grid-template-columns: repeat(3, 1fr);
}
.map-summary > p {
grid-column: 1 / -1;
border-top: 1px solid var(--line);
}
.class-map-shell {
grid-template-columns: 1fr;
}
.class-map,
.class-map-shell {
min-height: 600px;
}
.city-panel {
min-height: 340px;
border-top: 1px solid var(--line);
border-left: 0;
}
}
@media (max-width: 560px) {
.map-summary {
grid-template-columns: repeat(3, 1fr);
}
.map-summary > div {
min-height: 78px;
padding: 14px 10px;
text-align: center;
}
.map-summary > div:last-of-type {
border-right: 0;
}
.map-summary strong {
font-size: 25px;
}
.map-summary > p {
padding: 14px;
}
.class-map,
.class-map-shell {
min-height: 470px;
}
.map-caption {
top: 18px;
left: 18px;
}
.map-geo {
inset: 52px -8% 20px;
width: 116%;
height: calc(100% - 72px);
}
.map-source {
left: 14px;
bottom: 10px;
}
.city-marker-name {
font-size: 13px;
}
.city-panel {
padding: 22px 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.city-marker {
opacity: 1;
animation: none;
}
}
.data-guide { .data-guide {
display: grid; display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(280px, 1.1fr); grid-template-columns: minmax(0, 0.9fr) minmax(280px, 1.1fr);