From 03579c51d847466875a7790a481896946a04682e Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 18 Jul 2026 12:40:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=B0=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 102 +++++++++ package.json | 5 + src/data/class-map.ts | 68 ++++++ src/data/site.ts | 1 + src/pages/map.astro | 236 +++++++++++++++++++ src/pages/people.astro | 2 + src/styles/global.css | 502 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 916 insertions(+) create mode 100644 src/data/class-map.ts create mode 100644 src/pages/map.astro diff --git a/package-lock.json b/package-lock.json index 3472ba6..f251187 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d856641..43d19ed 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/data/class-map.ts b/src/data/class-map.ts new file mode 100644 index 0000000..1a11f87 --- /dev/null +++ b/src/data/class-map.ts @@ -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[]; + }>()).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 +}; diff --git a/src/data/site.ts b/src/data/site.ts index 1000569..a04e465 100644 --- a/src/data/site.ts +++ b/src/data/site.ts @@ -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/" } diff --git a/src/pages/map.astro b/src/pages/map.astro new file mode 100644 index 0000000..fdf24a7 --- /dev/null +++ b/src/pages/map.astro @@ -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; + 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; +--- + + +
+
+
+ 返回首页 +

Where We Are

+

从同一间教室,走向不同的城市

+

这里只记录城市,不显示学校校区、住址或实时位置。点击一座城市,看看愿意公开近况的同学。

+
+
+ +
+
+
+
{mapStats.cityCount}座城市
+
{mapStats.provinceCount}个省级地区
+
{mapStats.publicPeopleCount}位同学自愿公开
+

城市级展示 · 自愿公开 · 随时可撤回

+
+ +
+
+
+ 我们的足迹 + 真实省界 · 城市中心点投影 +
+ + + + + + + + + + + + { + provincePaths.map((province) => ( + + {province.name} + + )) + } + + + { + displayCities.map((city, index) => ( + + + + + {city.people.length} + {city.city} + + )) + } + + + +

省级边界数据:cn-atlas(2023)

+
+ + +
+ + +
+
+
+ + +
diff --git a/src/pages/people.astro b/src/pages/people.astro index 28d6645..644695d 100644 --- a/src/pages/people.astro +++ b/src/pages/people.astro @@ -12,6 +12,7 @@ import { site } from "../data/site";

Classmates

{peopleIntro.title}

{peopleIntro.text}

+ 查看班级地图 @@ -32,6 +33,7 @@ import { site } from "../data/site";
  • 照片:photo 可留空;如需放照片,将图片放入 public 后填写以 / 开头的路径。
  • 关键词:keywords 是数组,建议保留 1 到 3 个短词,列表页和个人页都会显示。
  • 个人页:currentStatushighlighttoPastSelffavoriteMemorymessageToClass 会生成详情内容。
  • +
  • 班级地图:只有明确同意公开城市的同学才添加到 src/data/class-map.ts;不要填写地址、学校校区或实时位置。
  • diff --git a/src/styles/global.css b/src/styles/global.css index bb83c84..d8ac35d 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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);