地图
This commit is contained in:
1 parent
b20f5d736d
commit
03579c51d8
7 files changed
+916
No files matched your search
@@ -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>
|
||||
Loaded 3 of 7 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user