Generated
+102
@@ -10,10 +10,15 @@
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"astro": "^7.1.0",
|
||||
"cn-atlas": "^0.1.2",
|
||||
"d3-geo": "^3.1.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"tsx": "^4.23.1"
|
||||
}
|
||||
},
|
||||
@@ -2086,6 +2091,16 @@
|
||||
"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": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -2101,6 +2116,13 @@
|
||||
"@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": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
|
||||
@@ -2138,6 +2160,27 @@
|
||||
"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": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -2553,6 +2596,12 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2701,6 +2750,30 @@
|
||||
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
|
||||
"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": {
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||
@@ -3223,6 +3296,15 @@
|
||||
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
|
||||
@@ -4393,6 +4475,26 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
|
||||
@@ -13,10 +13,15 @@
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"astro": "^7.1.0",
|
||||
"cn-atlas": "^0.1.2",
|
||||
"d3-geo": "^3.1.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"tsx": "^4.23.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -33,6 +33,7 @@ export const navItems = [
|
||||
{ label: "照片墙", href: "/gallery/" },
|
||||
{ label: "青春刊物", href: "/articles/" },
|
||||
{ label: "如今的我们", href: "/people/" },
|
||||
{ label: "班级地图", href: "/map/" },
|
||||
{ label: "综合试卷", href: "/exam/" },
|
||||
{ label: "留言墙", href: "/messages/" },
|
||||
{ label: "搜索", href: "/search/" }
|
||||
|
||||
@@ -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-atlas(2023)</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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
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("${escapeHtml(person.photo)}")"` : ""}>${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>
|
||||
@@ -12,6 +12,7 @@ import { site } from "../data/site";
|
||||
<p class="eyebrow">Classmates</p>
|
||||
<h1>{peopleIntro.title}</h1>
|
||||
<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>
|
||||
</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>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>src/data/class-map.ts</code>;不要填写地址、学校校区或实时位置。</li>
|
||||
</ol>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -755,6 +755,19 @@ h2 {
|
||||
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,
|
||||
.section-link {
|
||||
display: inline-flex;
|
||||
@@ -850,6 +863,495 @@ h2 {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(280px, 1.1fr);
|
||||
|
||||
Reference in New Issue
Block a user