-
-
Ready to explore individual cities?
-
- Switch to the interactive explorer to inspect city characteristics,
- evidence on climate solutions, and similar cities in detail.
-
+ {/* CITATION */}
+
+
+
Citation
+
+ Montfort, S., Nachtigall, F., Repke, T., Binder, C. R., & Creutzig, F.{" "}
+ A global typology of cities supporting coordinated climate action .
+ Forthcoming in Nature Climate Change.
+
+
+
+ {copyStatus === "copied" ? : }
+
+
+ {CITATION_BIBTEX}
+
+
+
+
+ {/* CTA */}
+
+
+
Ready to explore individual cities?
+
+ Switch to the interactive explorer to inspect city characteristics,
+ evidence on climate solutions, and similar cities in detail.
+
Go to Explorer
+
+
);
};
diff --git a/src/components/SiteFooter.jsx b/src/components/SiteFooter.jsx
new file mode 100644
index 0000000..d40dfce
--- /dev/null
+++ b/src/components/SiteFooter.jsx
@@ -0,0 +1,27 @@
+import React from "react";
+import { Link } from "react-router-dom";
+import "../styles/site-footer.css";
+
+const SiteFooter = () => (
+
+);
+
+export default SiteFooter;
diff --git a/src/components/TypeMap.jsx b/src/components/TypeMap.jsx
index 5a90e64..d34ec17 100644
--- a/src/components/TypeMap.jsx
+++ b/src/components/TypeMap.jsx
@@ -5,6 +5,10 @@ import { typeColors } from "../utils/coloring";
import { formatNumber } from "../utils/metrics";
const DARK_STYLE = "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json";
+const WORLD_BOUNDS = [
+ [-160, -50],
+ [170, 68],
+];
function webglAvailable() {
try {
@@ -18,91 +22,80 @@ function webglAvailable() {
}
}
+const scaledSize = (pop) => {
+ if (!pop || pop <= 0) return 3;
+ return 0.5 + Math.sqrt(pop / 500000);
+};
+
+const toGeoJSON = (arr) => ({
+ type: "FeatureCollection",
+ features: arr.map((c) => ({
+ type: "Feature",
+ geometry: { type: "Point", coordinates: [c.lon, c.lat] },
+ properties: {
+ name: c.name,
+ country: c.country,
+ population: c.characteristics?.population,
+ size: scaledSize(c.characteristics?.population),
+ color: typeColors[c.type] || "#888888",
+ },
+ })),
+});
+
+const syncData = (map, { cities, activeType }) => {
+ map
+ .getSource("active-type")
+ ?.setData(toGeoJSON(cities.filter((c) => c.type === activeType)));
+};
+
+const fitWorld = (map) =>
+ map.fitBounds(WORLD_BOUNDS, { padding: 8, animate: false });
+
const TypeMap = ({ cities, activeType, onWebglError }) => {
const mapContainer = useRef(null);
const mapRef = useRef(null);
+ const loadedRef = useRef(false);
+ const dataRef = useRef({ cities, activeType });
+ const onWebglErrorRef = useRef(onWebglError);
- // Adjust marker size by population
- const scaledSize = (pop) => {
- if (!pop || pop <= 0) return 3;
- return 0.5 + Math.sqrt(pop / 500000); // tweak as needed
- };
-
- const toGeoJSON = (arr, scalePopulation = false) => ({
- type: "FeatureCollection",
- features: arr.map((c) => ({
- type: "Feature",
- geometry: {
- type: "Point",
- coordinates: [c.lon, c.lat],
- },
- properties: {
- name: c.name,
- country: c.country,
- population: c.characteristics?.population,
- size: scalePopulation ? scaledSize(c.characteristics?.population) : 2,
- color: typeColors[c.type] || "#888888",
- },
- })),
- });
-
- const allCitiesGeoJSON = toGeoJSON(cities, false);
-
- const activeTypeGeoJSON = toGeoJSON(
- cities.filter((c) => c.type === activeType),
- true
- );
+ useEffect(() => {
+ onWebglErrorRef.current = onWebglError;
+ }, [onWebglError]);
useEffect(() => {
- if (!mapContainer.current || mapRef.current) return;
+ const container = mapContainer.current;
+ if (!container || mapRef.current) return undefined;
if (!webglAvailable()) {
- onWebglError?.();
- return;
+ onWebglErrorRef.current?.();
+ return undefined;
}
let map;
try {
map = new maplibregl.Map({
- container: mapContainer.current,
+ container,
style: DARK_STYLE,
- center: [10, 20],
- zoom: 1.4,
- minZoom: 1.4,
+ bounds: WORLD_BOUNDS,
+ fitBoundsOptions: { padding: 8 },
+ minZoom: 0,
+ renderWorldCopies: false,
+ attributionControl: { compact: true },
+ dragRotate: false,
+ pitchWithRotate: false,
});
- mapRef.current = map;
} catch (err) {
console.error("MapLibre init failed:", err);
- onWebglError?.();
- return;
+ onWebglErrorRef.current?.();
+ return undefined;
}
mapRef.current = map;
+ map.scrollZoom.disable();
+ map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
map.on("load", () => {
- // --- Background all cities ---
- map.addSource("all-cities", {
- type: "geojson",
- data: allCitiesGeoJSON,
- });
-
- map.addLayer({
- id: "all-cities-layer",
- type: "circle",
- source: "all-cities",
- paint: {
- "circle-radius": 2,
- "circle-color": "#999",
- "circle-opacity": 0.25,
- },
- });
-
- // --- Active type (pop-scaled) ---
- map.addSource("active-type", {
- type: "geojson",
- data: activeTypeGeoJSON,
- });
-
+ map.addSource("active-type", { type: "geojson", data: toGeoJSON([]) });
map.addLayer({
id: "active-type-layer",
type: "circle",
@@ -110,56 +103,57 @@ const TypeMap = ({ cities, activeType, onWebglError }) => {
paint: {
"circle-radius": ["get", "size"],
"circle-color": ["get", "color"],
- "circle-opacity": 0.9,
+ "circle-opacity": 0.85,
},
});
- // --- Hover popup ---
- const popup = new maplibregl.Popup({
- closeButton: false,
- closeOnClick: false,
- });
+ loadedRef.current = true;
+ syncData(map, dataRef.current);
+
+ const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false });
map.on("mousemove", "active-type-layer", (e) => {
const f = e.features[0];
const { name, country, population } = f.properties;
-
+ map.getCanvas().style.cursor = "pointer";
popup
.setLngLat(f.geometry.coordinates)
.setHTML(
- `
${name} , ${country}
Population: ${(formatNumber(population, 0))}`
+ `
${name} , ${country}
Population: ${formatNumber(population, 0)}`
)
.addTo(map);
});
- map.on("mouseleave", "active-type-layer", () => popup.remove());
+ map.on("mouseleave", "active-type-layer", () => {
+ map.getCanvas().style.cursor = "";
+ popup.remove();
+ });
+ });
+
+ let lastWidth = container.clientWidth;
+ const observer = new ResizeObserver(() => {
+ map.resize();
+ if (container.clientWidth !== lastWidth) {
+ lastWidth = container.clientWidth;
+ fitWorld(map);
+ }
});
+ observer.observe(container);
+
+ return () => {
+ observer.disconnect();
+ loadedRef.current = false;
+ mapRef.current = null;
+ map.remove();
+ };
}, []);
- // Update active type data when switching types
useEffect(() => {
- const map = mapRef.current;
- if (!map || !map.getSource("active-type")) return;
-
- const nextData = toGeoJSON(
- cities.filter((c) => c.type === activeType),
- true
- );
+ dataRef.current = { cities, activeType };
+ if (mapRef.current && loadedRef.current) syncData(mapRef.current, dataRef.current);
+ }, [cities, activeType]);
- map.getSource("active-type").setData(nextData);
- }, [activeType]);
-
- return (
-
- );
+ return
;
};
export default TypeMap;
diff --git a/src/components/TypeMetricsPlot.jsx b/src/components/TypeMetricsPlot.jsx
index aa09828..97622de 100644
--- a/src/components/TypeMetricsPlot.jsx
+++ b/src/components/TypeMetricsPlot.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useRef } from "react";
+import React, { useEffect, useRef, useState } from "react";
import Plotly from "plotly.js-dist";
import { metricList, formatNumber } from "../utils/metrics";
import { typeColors } from "../utils/coloring";
@@ -30,75 +30,89 @@ const RANGE = {
cdd: [0, 5000],
};
+const columnsForWidth = (width) => {
+ if (width >= 840) return 6;
+ if (width >= 480) return 3;
+ return 2;
+};
+
+const heightForRows = (rows) => (rows === 1 ? 340 : rows * 270);
+
const TypeMetricsPlot = ({ cities, activeType }) => {
+ const frameRef = useRef(null);
const plotRef = useRef(null);
+ const [columns, setColumns] = useState(6);
+
+ const rows = Math.ceil(METRIC_KEYS.length / columns);
+ const height = heightForRows(rows);
useEffect(() => {
- if (!plotRef.current || !cities?.length || !activeType) return;
+ const frame = frameRef.current;
+ if (!frame) return undefined;
+
+ const update = () => setColumns(columnsForWidth(frame.clientWidth));
+ update();
+
+ const observer = new ResizeObserver(update);
+ observer.observe(frame);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const plotElement = plotRef.current;
+ if (!plotElement || !cities?.length || !activeType) return;
const traces = [];
+ const annotations = [];
+ const color = typeColors[activeType];
METRIC_KEYS.forEach((key, i) => {
const def = metricList.find((m) => m.key === key);
if (!def) return;
+ const idx = i + 1;
const label = def.label;
- const allVals = cities
- .map((c) => ({
- val: Number(c.characteristics?.[key]),
- name: c.name,
- country: c.country,
- }))
- .filter((d) => !isNaN(d.val));
-
- const filtered = cities
- .filter((c) => c.type === activeType)
- .map((c) => ({
- val: Number(c.characteristics?.[key]),
- name: c.name,
- country: c.country,
- }))
- .filter((d) => !isNaN(d.val));
-
+ const toPoints = (list) =>
+ list
+ .map((c) => ({
+ val: Number(c.characteristics?.[key]),
+ name: c.name,
+ country: c.country,
+ }))
+ .filter((d) => !isNaN(d.val));
+
+ const allVals = toPoints(cities);
+ const filtered = toPoints(cities.filter((c) => c.type === activeType));
if (!allVals.length || !filtered.length) return;
- const idx = i + 1; // horizontal axis index
-
- // All cities (background)
traces.push({
- name: `${label} (all)`,
- x: allVals.map(() => label),
+ name: "All cities",
+ x: allVals.map(() => "All"),
y: allVals.map((v) => v.val),
type: "box",
marker: { color: "rgba(139,148,158,0.4)" },
- line: { color: "rgba(139,148,158,0.9)" },
- opacity: 0.4,
+ line: { color: "rgba(139,148,158,0.8)", width: 1 },
+ fillcolor: "rgba(139,148,158,0.12)",
boxpoints: false,
xaxis: "x" + idx,
yaxis: "y" + idx,
hoverinfo: "skip",
});
- // Selected type (foreground)
traces.push({
- name: `${label} (${activeType})`,
- x: filtered.map(() => label),
- y: filtered.map((v) => v.val), // REAL VALUES (no rounding)
+ name: activeType,
+ x: filtered.map(() => activeType),
+ y: filtered.map((v) => v.val),
type: "box",
- marker: {
- color: typeColors[activeType],
- size: 1
- },
- line: { color: typeColors[activeType], width: 2 },
+ marker: { color, size: 2 },
+ line: { color, width: 1.5 },
boxpoints: "outliers",
- jitter: 0.4,
- pointpos: 0,
opacity: 0.95,
customdata: filtered.map((v) => ({
name: v.name,
country: v.country,
- display: formatNumber(v.val, def.decimals), // display-only
+ display: formatNumber(v.val, def.decimals),
})),
hovertemplate:
`
%{customdata.name}, %{customdata.country} ` +
@@ -107,69 +121,96 @@ const TypeMetricsPlot = ({ cities, activeType }) => {
xaxis: "x" + idx,
yaxis: "y" + idx,
});
+
+ annotations.push({
+ text: def.unit
+ ? `${label}
${def.unit} `
+ : `${label}
`,
+ xref: `x${idx} domain`,
+ yref: `y${idx} domain`,
+ x: 0.5,
+ y: 1,
+ yanchor: "bottom",
+ yshift: 6,
+ showarrow: false,
+ font: { size: 11, color: "#c9d1d9" },
+ });
});
- // Layout: ALL BOX PLOTS IN A SINGLE ROW
const layout = {
uirevision: "stay",
+ height,
grid: {
- rows: 1,
- columns: METRIC_KEYS.length,
+ rows,
+ columns,
pattern: "independent",
+ xgap: 0.35,
+ ygap: rows > 1 ? 0.4 : 0,
},
showlegend: false,
- margin: { l: 40, r: 20, t: 10, b: 70 },
- height: 420, // more height for horizontal layout
+ annotations,
+ margin: { l: 44, r: 8, t: 44, b: 12 },
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)",
- font: { color: "#c9d1d9" },
+ font: { color: "#c9d1d9", size: 10 },
+ hoverlabel: { bgcolor: "#161b22", bordercolor: "#30363d", font: { color: "#e6edf3" } },
};
- // Configure axes for each boxplot
METRIC_KEYS.forEach((key, i) => {
const idx = i + 1;
- const l = RANGE[key][0];
- const u = RANGE[key][1];
+ const [lower, upper] = RANGE[key];
layout["yaxis" + idx] = {
type: LOG_SCALE[key] ? "log" : "linear",
gridcolor: "#21262d",
zerolinecolor: "#21262d",
+ tickformat: "~s",
automargin: true,
- range: LOG_SCALE[key]
- ? [Math.log10(l), Math.log10(u)]
- : [l, u],
+ range: LOG_SCALE[key] ? [Math.log10(lower), Math.log10(upper)] : [lower, upper],
};
layout["xaxis" + idx] = {
- tickangle: -45,
- automargin: true,
+ showticklabels: false,
+ showgrid: false,
+ zeroline: false,
+ fixedrange: true,
};
});
- const plotElement = plotRef.current;
-
- Plotly.newPlot(plotElement, traces, layout, {
+ Plotly.react(plotElement, traces, layout, {
displayModeBar: false,
responsive: true,
});
+ }, [cities, activeType, columns, rows, height]);
- const resize = () => {
- if (plotElement) {
- Plotly.Plots.resize(plotElement);
- }
- };
- window.addEventListener("resize", resize);
-
+ useEffect(() => {
+ const plotElement = plotRef.current;
return () => {
- window.removeEventListener("resize", resize);
- if (plotElement) {
- Plotly.purge(plotElement);
- }
+ if (plotElement) Plotly.purge(plotElement);
};
- }, [cities, activeType]);
-
- return
;
+ }, []);
+
+ return (
+
+
+
+
+ All cities
+
+
+
+ {activeType}
+
+
+ {/* Explicit height is required: explorer.css forces .js-plotly-plot to height: 100% */}
+
+
+ );
};
export default TypeMetricsPlot;
diff --git a/src/main.jsx b/src/main.jsx
index b8c35f4..e009eb2 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Explorer from "./components/Explorer.jsx";
import LandingPage from "./components/LandingPage.jsx";
+import ImpressumPage from "./components/ImpressumPage.jsx";
import { initializeAnalytics } from "./utils/analytics.js";
import "./index.css";
@@ -14,7 +15,7 @@ ReactDOM.createRoot(document.getElementById("root")).render(
} />
} />
- {/* fallback: send unknown routes to explorer */}
+ } />
} />
diff --git a/src/styles/explorer.css b/src/styles/explorer.css
index 86a9307..32fe228 100644
--- a/src/styles/explorer.css
+++ b/src/styles/explorer.css
@@ -76,18 +76,87 @@
right: 15px;
width: 348px;
background: rgba(0, 0, 0, 0.75);
- padding: 12px;
+ padding: 12px 4px 12px 12px;
border-radius: 4px;
- max-height: 97vh;
+ max-height: calc(100vh - 72px);
box-sizing: border-box;
- overflow-y: auto;
+ display: flex;
+ flex-direction: column;
z-index: 30;
}
+.explorer-root .info-panel-body {
+ position: relative;
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ padding-right: 8px;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
+}
+
+/* Feedback note (bottom right) */
+.explorer-root .feedback-note {
+ position: absolute;
+ right: 15px;
+ bottom: 12px;
+ z-index: 25;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ justify-content: center;
+ width: 348px;
+ max-width: calc(100% - 30px);
+ padding: 6px 10px;
+ box-sizing: border-box;
+ border-radius: 4px;
+ background: rgba(0, 0, 0, 0.6);
+ color: #6e7681;
+ font-size: 0.7rem;
+ line-height: 1.4;
+}
+
+.explorer-root .feedback-note a {
+ color: #8b949e;
+ text-decoration: none;
+ border-bottom: 1px solid rgba(139, 148, 158, 0.35);
+ transition: color 0.15s ease, border-color 0.15s ease;
+}
+
+.explorer-root .feedback-note a:hover,
+.explorer-root .feedback-note a:focus-visible {
+ color: #c9d1d9;
+ border-bottom-color: #c9d1d9;
+}
+
+.explorer-root .feedback-note-text-short {
+ display: none;
+}
+
+@media (max-width: 768px) {
+ .explorer-root .feedback-note-text-short {
+ display: inline;
+ }
+
+ .explorer-root .feedback-note {
+ right: 10px;
+ bottom: 60px;
+ }
+
+ .explorer-root .feedback-note-text {
+ display: none;
+ }
+
+ .explorer-root .info-panel {
+ max-height: calc(100vh - 118px);
+ }
+}
+
.explorer-root .info-close {
position: absolute;
- top: 6px;
- right: 8px;
+ top: -6px;
+ right: 0;
font-size: 1.2em;
color: #c9d1d9;
cursor: pointer;
diff --git a/src/styles/impressum.css b/src/styles/impressum.css
new file mode 100644
index 0000000..4496aaa
--- /dev/null
+++ b/src/styles/impressum.css
@@ -0,0 +1,107 @@
+.impressum-root {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background: radial-gradient(ellipse at top, #161b22 0%, #0d1117 35%, #07090c 100%);
+ color: #c9d1d9;
+ font-family: "Inter", system-ui, -apple-system, "Segoe UI", Arial, sans-serif;
+}
+
+.impressum-main {
+ box-sizing: border-box;
+ flex: 1;
+ width: 100%;
+ max-width: 1160px;
+ margin: 0 auto;
+ padding: clamp(32px, 6vw, 64px) clamp(16px, 4vw, 32px) clamp(48px, 8vw, 88px);
+}
+
+.impressum-back {
+ display: inline-block;
+ margin-bottom: 28px;
+ font-size: 0.88rem;
+ color: #8b949e;
+ text-decoration: none;
+}
+
+.impressum-back:hover {
+ color: #e6edf3;
+}
+
+.impressum-main h1 {
+ margin: 0 0 10px;
+ font-size: clamp(1.8rem, 4vw, 2.4rem);
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ color: #e6edf3;
+}
+
+.impressum-legal-basis {
+ margin: 0 0 32px;
+ font-size: 0.88rem;
+ color: #8b949e;
+}
+
+.impressum-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 32px;
+}
+
+@media (min-width: 860px) {
+ .impressum-legal-basis {
+ margin: 0 0 32px;
+ font-size: 0.88rem;
+ color: #8b949e;
+}
+
+.impressum-grid {
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
+ gap: 48px;
+ }
+}
+
+.impressum-details {
+ display: flex;
+ flex-direction: column;
+ gap: 28px;
+}
+
+.impressum-details h2 {
+ margin: 0 0 8px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #8b949e;
+}
+
+.impressum-details address,
+.impressum-details p {
+ margin: 0;
+ font-style: normal;
+ font-size: 0.95rem;
+ line-height: 1.7;
+}
+
+.impressum-map {
+ min-height: 320px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.impressum-map iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ min-height: 320px;
+ border: 0;
+}
+
+@media (min-width: 860px) {
+ .impressum-map,
+ .impressum-map iframe {
+ min-height: 420px;
+ }
+}
diff --git a/src/styles/landing.css b/src/styles/landing.css
index 32a0b11..3efb574 100644
--- a/src/styles/landing.css
+++ b/src/styles/landing.css
@@ -1,329 +1,645 @@
/* ------------------------------------------------------
- ROOT + GLOBAL TYPOGRAPHY
+ ROOT + LAYOUT PRIMITIVES
------------------------------------------------------ */
.landing-root {
+ --border: #21262d;
+ --border-soft: rgba(255, 255, 255, 0.08);
+ --panel: rgba(255, 255, 255, 0.025);
+ --text: #c9d1d9;
+ --muted: #8b949e;
+ --heading: #e6edf3;
+ --accent: #238636;
+ --gutter: clamp(16px, 4vw, 32px);
+
width: 100%;
min-height: 100vh;
- padding-left: 32px;
- padding-right: 32px;
- padding-bottom: 60px;
- background: radial-gradient(circle at top, #161b22 0%, #0d1117 45%, #050608 100%);
- color: #c9d1d9;
+ background: radial-gradient(ellipse at top, #161b22 0%, #0d1117 35%, #07090c 100%);
+ color: var(--text);
+ font-family: "Inter", system-ui, -apple-system, "Segoe UI", Arial, sans-serif;
overflow-x: hidden;
}
+.landing-root *,
+.landing-root *::before,
+.landing-root *::after {
+ box-sizing: border-box;
+}
+
.landing-root h1,
.landing-root h2,
-.landing-root h3,
-.landing-root h4 {
- font-family: "Inter", Arial, sans-serif;
+.landing-root h3 {
+ margin: 0;
+ color: var(--heading);
font-weight: 600;
letter-spacing: -0.02em;
- margin-top: 0;
}
.landing-root p {
- font-family: "Inter", Arial, sans-serif;
- margin-top: 0;
- margin-bottom: 12px;
+ margin: 0 0 14px;
font-size: 0.95rem;
- line-height: 1.55;
+ line-height: 1.65;
+}
+
+.landing-root p:last-child {
+ margin-bottom: 0;
+}
+
+.landing-container {
+ width: 100%;
+ max-width: 1160px;
+ margin: 0 auto;
+ padding: 0 var(--gutter);
+}
+
+.landing-narrow {
+ max-width: 880px;
+}
+
+.landing-section {
+ padding: clamp(56px, 8vw, 96px) 0;
+ border-top: 1px solid var(--border);
+}
+
+.landing-root .landing-h2 {
+ font-size: clamp(1.35rem, 2.2vw, 1.7rem);
+ line-height: 1.25;
+ margin-bottom: 14px;
+}
+
+.landing-section-header {
+ max-width: 680px;
+ margin-bottom: 28px;
+}
+
+.landing-section-header p {
+ color: var(--muted);
}
/* ------------------------------------------------------
- HERO SECTION
+ HERO
------------------------------------------------------ */
-.landing-root .landing-hero {
+.landing-hero {
position: relative;
+ isolation: isolate;
min-height: 100vh;
+ min-height: 100svh;
+ border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
+}
+
+.landing-hero + .landing-section {
+ border-top: 0;
+}
+
+.landing-hero::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ background: url("../assets/hero-network.webp") center / cover no-repeat;
+ opacity: 0.55;
+ pointer-events: none;
+ -webkit-mask-image: linear-gradient(to bottom, #000 60%, transparent);
+ mask-image: linear-gradient(to bottom, #000 60%, transparent);
+}
+
+@media (max-width: 700px) {
+ .landing-hero::before {
+ background-position: 8% 40%;
+ opacity: 0.45;
+ }
+}
+
+.landing-hero-inner {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding-top: 64px;
+ padding-bottom: 32px;
+}
+
+.landing-brand-row {
+ display: flex;
+ flex-wrap: wrap;
justify-content: center;
align-items: center;
+ gap: 12px 18px;
+ margin-bottom: 28px;
}
-.landing-root .landing-hero-inner {
- max-width: 960px;
- margin: 0 auto;
+.landing-brand-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 36px;
+ padding: 4px 8px;
+ transition: opacity 0.2s ease, transform 0.2s ease;
+}
+
+.landing-brand-link:hover,
+.landing-brand-link:focus-visible {
+ opacity: 0.8;
+ transform: translateY(-1px);
+}
+
+.landing-brand-logo {
+ display: block;
+ height: 24px;
+ width: auto;
+ max-width: 130px;
+ object-fit: contain;
}
.landing-root .landing-title {
- font-size: 2.7rem;
- margin-bottom: 12px;
+ font-size: clamp(2rem, 5vw, 3rem);
+ line-height: 1.12;
+ margin-bottom: 18px;
}
.landing-root .landing-subtitle {
- max-width: 720px;
- font-size: 1.05rem;
- color: #8b949e;
- line-height: 1.6;
+ max-width: 700px;
+ margin: 0 auto;
+ font-size: clamp(0.98rem, 1.4vw, 1.08rem);
+ color: var(--muted);
}
-.landing-root .landing-hero-actions {
- margin-top: 24px;
+.landing-hero-actions {
+ margin-top: 32px;
display: flex;
flex-wrap: wrap;
+ justify-content: center;
gap: 12px;
}
+.scroll-hint {
+ align-self: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ margin-bottom: 28px;
+ font-size: 0.85rem;
+ color: var(--muted);
+ text-decoration: none;
+ animation: fadeIn 1.8s ease-out;
+}
+
+.scroll-hint:hover {
+ color: var(--text);
+}
+
+.scroll-chevron {
+ font-size: 1.4rem;
+ line-height: 1;
+ animation: bounce 1.8s infinite;
+}
+
+@keyframes bounce {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(6px); }
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
/* ------------------------------------------------------
BUTTONS
------------------------------------------------------ */
-.landing-root .landing-btn-primary,
-.landing-root .landing-btn-secondary {
- padding: 8px 18px;
+.landing-btn-primary,
+.landing-btn-secondary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 9px 20px;
border-radius: 999px;
- border: 1px solid #238636;
- background-color: #238636;
+ border: 1px solid var(--accent);
+ background-color: var(--accent);
color: #ffffff;
+ font: inherit;
font-size: 0.9rem;
+ line-height: 1.2;
cursor: pointer;
text-decoration: none;
+ transition: background-color 0.15s ease, border-color 0.15s ease;
}
-.landing-root .landing-btn-primary:hover {
+.landing-btn-primary:hover {
background-color: #2ea043;
}
-.landing-root .landing-btn-secondary {
+.landing-btn-secondary {
border-color: #30363d;
background-color: #161b22;
- color: #c9d1d9;
+ color: var(--text);
}
-.landing-root .landing-btn-secondary:hover {
+.landing-btn-secondary:hover {
background-color: #21262d;
}
/* ------------------------------------------------------
- INTRO SECTION
+ INTRO
------------------------------------------------------ */
-.landing-root .landing-intro {
- padding: 150px 0 75px;
+.landing-intro-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 32px 64px;
+ align-items: start;
}
-.landing-root .landing-intro-grid {
- max-width: 960px;
- margin: 0 auto;
+@media (min-width: 900px) {
+ .landing-intro-grid {
+ grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
+ }
+}
+
+.landing-facts {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.landing-facts li {
+ padding: 14px 0;
+ border-bottom: 1px solid var(--border);
+ font-size: 0.92rem;
+ line-height: 1.55;
+}
+
+.landing-facts li:first-child {
+ padding-top: 4px;
+}
+
+.landing-facts li:last-child {
+ border-bottom: 0;
+}
+
+.landing-facts strong {
+ color: var(--heading);
+ font-weight: 600;
+}
+
+/* ------------------------------------------------------
+ TYPOLOGY: TYPE SELECTOR
+------------------------------------------------------ */
+.type-tabs {
display: grid;
- gap: 40px;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 16px;
}
-@media (min-width: 900px) {
- .landing-root .landing-intro-grid {
- grid-template-columns: 1.3fr 1fr;
+@media (max-width: 900px) {
+ .type-tabs {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
-.landing-root .landing-bullets {
- padding-left: 18px;
+.type-tab {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+ padding: 14px 16px 14px 18px;
+ border: 1px solid var(--border-soft);
+ border-radius: 10px;
+ background: var(--panel);
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ overflow: hidden;
+ transition: border-color 0.2s ease, background-color 0.2s ease;
+}
+
+.type-tab::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background: var(--type-color);
+ opacity: 0.45;
+ transition: opacity 0.2s ease;
+}
+
+.type-tab:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.type-tab:focus-visible {
+ outline: 2px solid var(--type-color);
+ outline-offset: 2px;
+}
+
+.type-tab[aria-selected="true"] {
+ border-color: var(--type-color);
+ background: rgba(255, 255, 255, 0.04);
+ background: color-mix(in srgb, var(--type-color) 9%, transparent);
+}
+
+.type-tab[aria-selected="true"]::before {
+ opacity: 1;
+}
+
+.type-tab-label {
+ font-size: 0.72rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--type-color);
+}
+
+.type-tab-name {
font-size: 0.9rem;
- color: #c9d1d9;
+ font-weight: 500;
+ line-height: 1.35;
+ color: var(--heading);
}
-.landing-root .landing-bullets li {
- margin-bottom: 6px;
+.type-tab-count {
+ margin-top: auto;
+ font-size: 0.78rem;
+ color: var(--muted);
}
/* ------------------------------------------------------
- TYPOLOGY EXPLAINER
- ------------------------------------------------------ */
+ TYPOLOGY: DETAIL + VISUALIZATIONS
+------------------------------------------------------ */
+.type-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
-.landing-root .landing-typology {
+.type-detail {
display: grid;
- grid-template-columns: minmax(0, 1.1fr) minmax(0, 2fr);
- gap: 36px;
- padding: 40px 40px;
- border-top: 1px solid #21262d;
- align-items: flex-start;
+ grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
+ gap: 16px;
}
@media (max-width: 900px) {
- .landing-root .landing-typology {
- grid-template-columns: 1fr;
+ .type-detail {
+ grid-template-columns: minmax(0, 1fr);
}
}
-/* Left column */
+.type-detail-text,
+.viz-panel {
+ min-width: 0;
+ padding: clamp(16px, 2vw, 24px);
+ border: 1px solid var(--border-soft);
+ border-radius: 12px;
+ background: var(--panel);
+}
-.landing-root .typology-left {
- max-width: 640px;
- max-height: 120vh;
- padding-right: 6px;
+.type-detail-text h3 {
+ margin: 8px 0 10px;
+ font-size: clamp(1.1rem, 1.6vw, 1.3rem);
+ line-height: 1.3;
}
-.landing-root .typology-heading {
- font-size: 1.3rem;
- margin-bottom: 18px;
+.landing-root .type-detail-subtitle {
+ color: var(--muted);
+ font-size: 0.92rem;
}
-.landing-root .typology-scroll-container {
+.type-chips {
display: flex;
- flex-direction: column;
- gap: 18px;
- padding-top: 38px;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin: 18px 0;
}
-/* Type cards */
+.type-chip {
+ padding: 3px 10px;
+ border: 1px solid var(--border-soft);
+ border-radius: 999px;
+ font-size: 0.75rem;
+ color: var(--text);
+ white-space: nowrap;
+}
-.landing-root .typology-section {
- padding: 16px 18px;
- margin-bottom: 8px;
- border-radius: 10px;
- border: 1px solid rgba(255,255,255,0.04);
- background: rgba(255,255,255,0.03);
- transition: all 0.25s ease;
- cursor: pointer;
+.type-chip-strong {
+ border-color: var(--type-color);
+ color: var(--heading);
+}
+
+.type-detail-list {
+ display: grid;
+ gap: 8px;
+ margin: 0;
+ padding-left: 18px;
+ font-size: 0.9rem;
+ line-height: 1.55;
}
-.landing-root .typology-section:hover {
- background: rgba(255,255,255,0.06);
- transform: translateY(-2px);
+.type-detail-list li::marker {
+ color: var(--type-color);
}
-.landing-root .typology-section-active {
- border-color: #238636;
- background: rgba(35,134,54,0.12);
- box-shadow: 0 0 0 1px rgba(35,134,54,0.3), 0 6px 12px rgba(0,0,0,0.4);
- transform: translateY(-2px);
+.viz-panel-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 4px 16px;
+ margin-bottom: 14px;
}
-.landing-root .typology-subtitle {
- color: #8b949e;
- font-size: 0.85rem;
- margin: 0 0 6px 0;
+.viz-panel-title {
+ font-size: 0.98rem;
+ font-weight: 600;
+ color: var(--heading);
}
-/* ------------------------------------------------------
- VISUALIZATION PANELS
- ------------------------------------------------------ */
+.viz-panel-caption {
+ font-size: 0.8rem;
+ color: var(--muted);
+}
-.landing-root .typology-right-inner {
+.viz-panel-map {
display: flex;
flex-direction: column;
- gap: 24px;
- position: sticky;
- top: 60px;
- padding-right: 24px;
}
-.landing-root .typology-viz-panel {
- border-radius: 12px;
- background: rgba(255,255,255,0.03);
- border: 1px solid rgba(255,255,255,0.06);
- padding: 16px 20px 134px 20px;
- backdrop-filter: blur(6px);
+.type-map {
+ flex: 1;
+ display: flex;
+ min-height: 340px;
}
-.landing-root .typology-map-panel {
- border-radius: 12px;
- background: rgba(255,255,255,0.03);
- border: 1px solid rgba(255,255,255,0.06);
- margin-top: 140px;
- padding: 16px 20px;
- backdrop-filter: blur(6px);
+.type-map-canvas {
+ flex: 1;
+ min-width: 0;
+ border-radius: 8px;
+ overflow: hidden;
+ background: #0e1116;
}
-.landing-root .map-fallback {
- width: 100%;
- height: 480px;
- border-radius: 10px;
- background: #1b1f24;
- color: #c9d1d9;
+.type-map .maplibregl-ctrl-attrib.maplibregl-compact {
+ background: rgba(13, 17, 23, 0.75);
+ color: var(--muted);
+}
+
+.type-map .maplibregl-ctrl-attrib a {
+ color: var(--muted);
+}
+
+.type-map .maplibregl-ctrl-group {
+ background: rgba(22, 27, 34, 0.9);
+ border: 1px solid var(--border-soft);
+ box-shadow: none;
+}
+
+.type-map .maplibregl-ctrl-group button + button {
+ border-top-color: var(--border-soft);
+}
+
+.type-map .maplibregl-ctrl-group button .maplibregl-ctrl-icon {
+ filter: invert(0.85);
+}
+
+.type-map .maplibregl-popup-content {
+ background: #161b22;
+ color: var(--text);
+ font-size: 0.8rem;
+ border: 1px solid var(--border-soft);
+ border-radius: 6px;
+}
+
+.type-map .maplibregl-popup-tip {
+ display: none;
+}
+
+@media (max-width: 600px) {
+ .type-map {
+ min-height: 260px;
+ }
+}
+
+.map-fallback {
display: flex;
align-items: center;
justify-content: center;
- text-align: center;
padding: 20px;
+ border-radius: 8px;
+ background: #161b22;
+ color: var(--muted);
font-size: 0.9rem;
- box-shadow: 0 2px 20px rgba(0,0,0,0.3);
- border: 1px solid rgba(255,255,255,0.06);
+ text-align: center;
}
-.landing-root .typology-viz-title {
- font-size: 1rem;
- margin-bottom: 12px;
+.metrics-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 18px;
+ margin-bottom: 8px;
+ font-size: 0.78rem;
+ color: var(--muted);
}
-.landing-root .typology-viz-subtitle {
- color: #8b949e;
- font-size: 0.8rem;
+.metrics-legend span {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
}
-.landing-root .typology-metrics-plot {
- width: 100%;
- height: 280px;
- margin-top: 8px;
+.metrics-legend-swatch {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+}
+
+.metrics-legend-swatch-all {
+ background: rgba(139, 148, 158, 0.55);
}
-.landing-root .typology-map-plot {
+.metrics-plot-frame {
width: 100%;
- height: 400px;
- border-radius: 10px;
- overflow: hidden;
+ min-width: 0;
}
/* ------------------------------------------------------
- MAPLIBRE WRAPPER
- ------------------------------------------------------ */
-
-.landing-root .maplibre-container {
- border-radius: 10px;
- overflow: hidden;
- box-shadow: 0 2px 20px rgba(0,0,0,0.4);
- transition: opacity 0.3s ease;
+ CITATION
+------------------------------------------------------ */
+.landing-citation {
+ text-align: center;
}
-/* ------------------------------------------------------
- FOOTER
- ------------------------------------------------------ */
+.landing-root .citation-text {
+ margin-bottom: 20px;
+}
-.landing-root .landing-footer {
- border-top: 1px solid #21262d;
- padding: 32px 0 48px;
- margin-top: 64px;
+.citation-bibtex-wrap {
+ position: relative;
+ text-align: left;
}
-.landing-root .landing-footer-inner {
- max-width: 960px;
- margin: 0 auto;
- display: flex;
- flex-wrap: wrap;
- gap: 20px;
+.citation-copy-icon {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ display: inline-flex;
+ align-items: center;
justify-content: center;
- text-align: center;
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
-.landing-root .landing-footer-inner h2 {
- margin: 0 0 6px 0;
+.citation-copy-icon:hover,
+.citation-copy-icon:focus-visible {
+ color: var(--heading);
+ background: rgba(255, 255, 255, 0.06);
+ border-color: var(--border-soft);
}
-/* ------------------------------------------------------
- SCROLL HINT
- ------------------------------------------------------ */
+.citation-copy-copied {
+ color: #3fb950;
+}
-.scroll-hint {
- position: absolute;
- bottom: 40px;
- text-align: center;
- font-size: 0.85rem;
- color: #8b949e;
- opacity: 0.85;
- animation: fadeIn 1.8s ease-out;
+.citation-copy-error {
+ color: #f85149;
}
-.scroll-chevron {
- font-size: 1.4rem;
- animation: bounce 1.8s infinite;
- margin-top: 6px;
+.citation-bibtex {
+ margin: 0;
+ padding: 14px 48px 14px 16px;
+ border: 1px solid var(--border-soft);
+ border-radius: 10px;
+ background: #0b1016;
+ color: #d8e2ec;
+ font-size: 0.82rem;
+ line-height: 1.55;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
}
-@keyframes bounce {
- 0%, 100% { transform: translateY(0); }
- 50% { transform: translateY(6px); }
+/* ------------------------------------------------------
+ CALL TO ACTION
+------------------------------------------------------ */
+.landing-cta {
+ text-align: center;
}
-@keyframes fadeIn {
- from { opacity: 0; }
- to { opacity: 0.85; }
+.landing-root .landing-cta p {
+ max-width: 560px;
+ margin: 0 auto 24px;
+ color: var(--muted);
}
diff --git a/src/styles/site-footer.css b/src/styles/site-footer.css
new file mode 100644
index 0000000..d078d83
--- /dev/null
+++ b/src/styles/site-footer.css
@@ -0,0 +1,50 @@
+.site-footer {
+ border-top: 1px solid #21262d;
+ background: #07090c;
+ color: #8b949e;
+ font-family: "Inter", system-ui, -apple-system, "Segoe UI", Arial, sans-serif;
+ font-size: 0.82rem;
+}
+
+.site-footer-inner {
+ box-sizing: border-box;
+ width: 100%;
+ padding: 24px clamp(20px, 4.5vw, 64px);
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 14px 40px;
+}
+
+.site-footer .site-footer-ack {
+ flex: 1 1 auto;
+ min-width: 0;
+ margin: 0;
+ font-size: inherit;
+ line-height: 1.6;
+}
+
+.site-footer-links {
+ flex-shrink: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 22px;
+}
+
+.site-footer-links a {
+ color: #8b949e;
+ text-decoration: none;
+ transition: color 0.15s ease;
+}
+
+.site-footer-links a:hover,
+.site-footer-links a:focus-visible {
+ color: #e6edf3;
+}
+
+@media (max-width: 640px) {
+ .site-footer-inner {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}