map / src /app.js
PH70248's picture
Deploy bookmark loading and analytics exclusions
cd96fd5 verified
Raw
History Blame Contribute Delete
369 kB
import {
applyCityMapSpecialBookLayout,
CITY_MAP_BOOK_BOTTOM_FORBIDDEN_TOP_OFFSET,
CITY_MAP_BOOK_FORBIDDEN_GAP,
CITY_MAP_BOOK_HOVER_SAFE_SCALE,
CITY_MAP_BOOK_TOP_FORBIDDEN_BOTTOM,
cityMapBookTopForbiddenBottom,
hasCompleteCityMapSpecialBookLayout,
} from "./cityMapBookLayout.js?v=20260714-predeploy-010";
import {
applySnapToGuides,
} from "./layoutWorkbenchCore.js?v=20260714-predeploy-010";
import {
buildCityTileUrl,
normalizeCityTileStyles,
resolveCityTileProvider,
runtimeCityTileConfig,
} from "./tileProviders.js?v=20260714-predeploy-010";
import {
SPECIAL_BOOK_OFFSETS,
SPECIAL_CATEGORY_STACK_OFFSETS,
SPECIAL_DEFINITIONS,
} from "./specialConfig.js?v=20260714-predeploy-010";
import {
SHARE_HALFTONE_DOT_RULE,
shareHalftoneCropRect,
shareHalftoneDotAlpha,
shareHalftoneDotRadius,
shareHalftoneDotToneForPixel,
shareHalftoneFeatureColorPair,
shareHalftoneNoisyTone,
shareHalftonePlateColors,
shareHalftoneSamplePoint,
} from "./shareHalftone.js?v=20260714-predeploy-010";
import {
SAVED_BOOKS_STORAGE_KEY,
buildFixedShelfRows,
normalizeSavedBookRecords,
paginateShelfItems,
removeSavedBook,
upsertSavedBook,
} from "./savedBookshelf.js?v=20260714-predeploy-010";
import {
bookshelfLocationLabel,
combinedLocationHeading,
locationHeading,
} from "./locationLabels.js?v=20260714-predeploy-010";
import {
SEARCH_PINYIN_ALIASES,
SEARCH_TOPIC_GROUPS,
SEARCH_TOPIC_ALIASES,
deriveSearchTopicAliasMatchesFromText,
deriveSearchTopicsFromText,
} from "./searchTopics.js?v=20260714-predeploy-010";
import {
initAnalytics,
trackEvent,
} from "./analytics.js?v=20260714-predeploy-010";
const VIEWBOX = { width: 1440, height: 920 };
const BASE_RADIUS = 430;
const DRIFT_FRAME_MS = 1000 / 60;
const DRIFT_DEGREES_PER_MS = 0.00052;
const GLOBE_PATH_CACHE_LIMIT = 2048;
const GLOBE_PATH_CACHE_CENTER_STEP = 0.05;
const GLOBE_PATH_CACHE_RADIUS_STEP = 0.05;
const HALFTONE_STEP = 14;
const HALFTONE_ACTIVE_FRAME_MS = 1000 / 30;
const HALFTONE_ACTIVE_AFTER_POINTER_MS = 900;
const HALFTONE_SETTLE_EPSILON = 0.45;
const CAPITAL_MARKER_SCALE = 1.16;
const CITY_LABEL_EDGE_PADDING = 18;
const GLOBAL_OCEAN_TEXTURE_TILE = 228;
const CITY_OCEAN_TEXTURE_TILE = 180;
const CITY_OCEAN_TRANSITION_MIN_MS = 1750;
const CITY_OCEAN_TRANSITION_SETTLE_MS = 250;
const CITY_OCEAN_TRANSITION_DRIFT_MS = 30000;
const CITY_SPECIAL_LAND_REVEAL_MIN_MS = 220;
const CITY_SPECIAL_LAND_REVEAL_MS = 460;
const CITY_SPECIAL_BOOK_REVEAL_AFTER_LAND_MS = 120;
const INITIAL_VIEW = {
center: [132, 34],
zoom: 1.78,
};
const CITY_LABEL_METRICS = {
width: 62,
height: 18.6,
zhCenterY: 5.9,
enCenterY: 14.2,
};
const CITY_TILE_COAST_SHADOW_RADIUS = 12;
const CITY_TILE_COAST_SHADOW_MAX_ALPHA = 16;
const CITY_TILE_COAST_SHADOW_FALLOFF = 1.45;
const CITY_TILE_COAST_SHADOW_COLOR = [32, 79, 80];
const CITY_TILE_PIXEL_CACHE_LIMIT = 64;
const CITY_TILE_PRELOAD_LIMIT = 260;
const cityTilePixelCache = new Map();
const cityTileImagePreloadCache = new Map();
const citySpecialOceanTextureCache = new Set();
function cssAttributeValue(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
const continentShade = {
Asia: "land-strong",
Europe: "land-mid",
Africa: "land-soft",
"North America": "land-mid",
"South America": "land-soft",
Oceania: "land-faint",
Antarctica: "land-ice",
};
const COUNTRY_LABEL_EXCLUDED_ISO = new Set(["ATA", "HKG", "MAC", "TWN"]);
const COUNTRY_LABEL_OVERRIDES = new Map([
["CHN", "China"],
["COD", "DR Congo"],
["COG", "Congo"],
["CZE", "Czechia"],
["BIH", "Bosnia & Herzegovina"],
["CAF", "Central African Rep."],
["TLS", "Timor-Leste"],
["USA", "United States"],
["GBR", "United Kingdom"],
]);
const COUNTRY_LABEL_POLICY = {
overviewMaxRank: 5,
detailMaxRank: 6,
detailZoom: 2.28,
tinyMinZoom: 2.38,
microMinZoom: 2.82,
};
// Keep country labels away from the clipped rim; labels entering/leaving this
// zone while the globe turns otherwise flicker at the display edge.
const COUNTRY_LABEL_EDGE_RATIO = 0.68;
const MUSIC_TRACKS = {
light: {
title: "Car 24",
artist: "菅野よう子",
},
dark: {
title: "Road to the West",
artist: "The Seatbelts",
},
};
const MUSIC_PREFS_KEY = "world-citybook-music";
const DEFAULT_MUSIC_VOLUME = 0.24;
const ENGAGEMENT_THRESHOLDS_SECONDS = [15, 30, 60];
const TICKET_STACK_MAX_VISIBLE = 3;
const TICKET_STACK_PEEK = 34;
const TICKET_CUT_MAX_RADIUS = 9.5;
const TICKET_CUT_BOTTOM_GAP = 7;
const SHARE_TITLE_ORIGINAL_MIN_SCALE = 0.62;
const SHARE_TITLE_ORIGINAL_LONG_LATIN_MIN_SCALE = 0.5;
const SHARE_TITLE_ORIGINAL_SAFE_INSET = 8;
const BOOKSHELF_DESKTOP_PAGE_SIZE = 12;
const BOOKSHELF_MOBILE_PAGE_SIZE = 6;
const BOOKSHELF_DESKTOP_ROW_SIZE = 6;
const BOOKSHELF_MOBILE_ROW_SIZE = 3;
const SEARCH_RESULT_LIMIT = 8;
const SEARCH_MIN_QUERY_LENGTH = 1;
const SEARCH_NO_RESULT_ANALYTICS_MIN_LENGTH = 2;
const SEARCH_NO_RESULT_ANALYTICS_IDLE_MS = 1200;
const SEARCH_BOOKMARK_REPLACE_MS = 180;
const SEARCH_SUGGESTION_IDLE_MS = 950;
const SEARCH_SUGGESTION_ISSUES = [
"城市记忆",
"日常生活",
"公共空间",
"殖民史",
"移民",
"港口城市",
"城市治理",
"街区",
"城市规划",
"建筑",
"现代性",
"全球化",
"边境",
"城市更新",
"城市空间",
"地方记忆",
"老街旧事",
"口述史",
"街道",
"街巷",
"多族群",
"殖民遗产",
"环境史",
"现代主义",
"城市史",
"城市观察",
"城市风物",
"城市气质",
"城市传记",
"旅行非虚构",
"工业",
"科技",
"贸易",
"商业",
"旅行文学",
"河流",
"水系",
"非正规",
"非正式",
"山体",
];
const SEARCH_SUGGESTION_PUBLISHERS = [
"生活·读书·新知三联书店",
"北京大学出版社",
"南京大学出版社",
"上海译文出版社",
"上海人民出版社",
"上海文艺出版社",
];
const SEARCH_SUGGESTION_AUTHORS = [
"陈丹燕",
"大卫·哈维",
"罗伯特·休斯",
"简·莫里斯",
"保罗·索鲁",
"奥尔罕·帕慕克",
];
const SPECIAL_CATEGORY_EN = new Map([
["城市气质", "Urban Temperament"],
["在地出版", "Local Publishing"],
["旧城底图", "Historical Base Map"],
["建筑在场", "Architecture in Place"],
["老街旧事", "Street Histories"],
["日常倒影", "Everyday Reflections"],
["线路漫游", "Walking Routes"],
]);
const CITY_MAP_TOP_ROW_CATEGORIES = [
"城市气质",
"在地出版",
"旧城底图",
"建筑在场",
];
const CITY_MAP_CATEGORY_ORDER = [
...CITY_MAP_TOP_ROW_CATEGORIES,
"老街旧事",
"日常倒影",
"线路漫游",
];
const CITY_MAP_CATEGORY_ORDER_BY_SPECIAL = new Map([
["hong-kong", [
...CITY_MAP_TOP_ROW_CATEGORIES,
"日常倒影",
"老街旧事",
"线路漫游",
]],
]);
const CITY_MAP_CATEGORY_ORDER_INDEX = new Map(
CITY_MAP_CATEGORY_ORDER.map((category, index) => [category, index]),
);
const CITY_MAP_CATEGORY_ORDER_INDEX_BY_SPECIAL = new Map(
Array.from(CITY_MAP_CATEGORY_ORDER_BY_SPECIAL.entries(), ([specialId, order]) => [
specialId,
new Map(order.map((category, index) => [category, index])),
]),
);
const CITY_MAP_TOP_ROW_CATEGORY_SET = new Set(CITY_MAP_TOP_ROW_CATEGORIES);
const CITY_MAP_SPECIAL_BOOK_EDGE_ALIGNMENTS = {
beijing: [
{
edge: "top",
ids: [
"chn-beijing--douban-1070711",
"chn-beijing--douban-36159853",
],
},
{
edge: "top",
ids: [
"chn-beijing--homeshop-wear",
"chn-beijing--douban-37508268",
],
},
{
edge: "top",
ids: [
"chn-beijing--douban-25751679",
"chn-beijing--douban-37131044",
],
},
{
edge: "bottom",
ids: [
"chn-beijing--douban-33395164",
"chn-beijing--douban-30469133",
"chn-beijing--douban-34991723",
"chn-beijing--douban-37134106",
"chn-beijing--isbn-9787540265052",
"chn-beijing--douban-26580056",
],
},
],
"hong-kong": [
{
edge: "bottom",
ids: [
"chn-hong-kong--douban-35452941",
"chn-hong-kong--douban-36285315",
"chn-hong-kong--manual-xiangtu-hongkong",
"chn-hong-kong--external-sham-shui-po-101",
],
},
{
edge: "top",
ids: [
"chn-hong-kong--douban-27004436",
"chn-hong-kong--manual-power-generation-urban-renewal",
],
},
],
};
const SHARE_HALFTONE_SPECIAL_PALETTES = new Map([
["lka-colombo--douban-36458135", [
{ r: 98, g: 164, b: 132 },
{ r: 247, g: 187, b: 166 },
]],
["mmr-yangon--douban-35166731", [
{ r: 211, g: 134, b: 99 },
{ r: 245, g: 189, b: 120 },
]],
["jpn-tokyo--douban-36448234", [
{ r: 243, g: 232, b: 11 },
{ r: 35, g: 24, b: 3 },
]],
["kor-seoul--external-seoul-mini-mountains", [
{ r: 213, g: 206, b: 199 },
{ r: 245, g: 106, b: 193 },
]],
]);
let globeFeaturePathId = 0;
const GLOBAL_TOOLTIP_SAFE_ZONE = {
leftRatio: 0.42,
minLeft: 520,
top: 112,
right: 28,
bottom: 78,
inset: 18,
heroRightPadding: 28,
footerTopPadding: 10,
};
const DETAIL_COPY_ANCHOR_RATIO = 0.70;
const DETAIL_HERO_CLEARANCE = 20;
const DETAIL_WIDE_CARD_THRESHOLD = 860;
const DETAIL_WIDE_HERO_OVERLAP = 112;
const TOOLTIP_CARD_ANCHOR_RATIO = 0.33;
const CITY_SPECIAL_BOOK_SAFE_ZONE = {
side: 86,
mobileSide: 70,
top: 142,
mobileTop: 112,
bottom: 190,
mobileBottom: 148,
};
const state = {
center: [...INITIAL_VIEW.center],
zoom: INITIAL_VIEW.zoom,
theme: "light",
dragging: null,
driftFrame: null,
lastDriftAt: 0,
activePoint: null,
hoverPoint: null,
tooltipPoint: null,
activeBookIndexes: new Map(),
savedBooks: new Set(),
savedBookRecords: [],
bookshelfOpen: false,
bookshelfPage: 0,
bookshelfHoverPointer: null,
bookshelfHoverObserver: null,
bookshelfRenderSignature: "",
bookCardMode: "ticket",
detailOrigin: null,
detailReturnTarget: null,
tooltipPointer: null,
activeSpecialId: null,
returnViewBeforeSpecial: null,
activeSpecialStackKey: null,
citySpecialReadyTimer: null,
citySpecialLandRevealTimer: null,
specialStackIndexes: new Map(),
specialStackRenderData: new Map(),
specialBookDrag: null,
specialBookDraftPoints: new Map(),
specialBookRenderData: new Map(),
specialBookSuppressedClickKey: null,
activeMapStyleId: "",
customMapboxStyleId: "",
viewAnimationFrame: null,
globePathCache: new Map(),
worldRenderKeys: "",
search: {
index: [],
query: "",
results: [],
activeIndex: -1,
open: false,
suggestion: null,
suggestionTimer: null,
suggestionRolls: 0,
acceptedSuggestions: 0,
shownSuggestions: new Set(),
},
searchNavigation: {
token: 0,
closeTimer: null,
},
analytics: {
searchStarted: false,
lastNoResultQuery: "",
pendingNoResultQuery: "",
noResultTimer: null,
engagementTimers: [],
engagementMarks: new Set(),
assetLoadTrackingReady: false,
},
shareControlsHideTimer: null,
audio: {
context: null,
musicTrack: "light",
musicPlaying: false,
musicMuted: false,
musicVolume: DEFAULT_MUSIC_VOLUME,
musicDrag: null,
musicFadeFrame: null,
musicVolumeTimer: null,
cityMapLoading: null,
},
halftone: {
initialized: false,
frame: null,
resizeTimer: null,
context: null,
width: 0,
height: 0,
dpr: 1,
pointerX: 0,
pointerY: 0,
targetX: 0,
targetY: 0,
pulse: 0,
lastDrawAt: 0,
activeUntil: 0,
reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
},
};
const elements = {
mapStage: document.querySelector(".map-stage"),
halftoneCanvas: document.querySelector("#halftoneCanvas"),
cityTileLayer: document.querySelector("#cityTileLayer"),
mapSvg: document.querySelector("#mapSvg"),
themeToggle: document.querySelector("#themeToggle"),
bookshelfToggle: document.querySelector("#bookshelfToggle"),
bookshelfCount: document.querySelector("#bookshelfCount"),
bookshelfView: document.querySelector("#bookshelfView"),
bookshelfClose: document.querySelector("#bookshelfClose"),
bookshelfGrid: document.querySelector("#bookshelfGrid"),
bookshelfEmpty: document.querySelector("#bookshelfEmpty"),
bookshelfPager: document.querySelector("#bookshelfPager"),
bookshelfPrev: document.querySelector("#bookshelfPrev"),
bookshelfNext: document.querySelector("#bookshelfNext"),
bookshelfPageLabel: document.querySelector("#bookshelfPageLabel"),
specialBackButton: document.querySelector("#specialBackButton"),
globeLayer: document.querySelector("#globeLayer"),
worldLayer: document.querySelector("#worldLayer"),
highlightLayer: document.querySelector("#highlightLayer"),
cityLayer: document.querySelector("#cityLayer"),
cityLabelLayer: document.querySelector("#cityLabelLayer"),
specialOverlaySvg: document.querySelector("#specialOverlaySvg"),
specialLabelLayer: document.querySelector("#specialLabelLayer"),
specialBookLayer: document.querySelector("#specialBookLayer"),
cityTooltip: document.querySelector("#cityTooltip"),
sharePreview: document.querySelector("#sharePreview"),
heroCopy: document.querySelector(".hero-copy"),
heroKicker: document.querySelector(".hero-kicker"),
heroTagline: document.querySelector(".hero-tagline"),
searchCapsule: document.querySelector(".search-capsule"),
searchInput: document.querySelector(".search-capsule input"),
searchClear: document.querySelector(".search-clear"),
searchSuggestion: document.querySelector(".search-suggestion"),
searchResults: document.querySelector("#searchResults"),
musicSignature: document.querySelector("#musicSignature"),
musicToggle: document.querySelector("#musicToggle"),
musicTitle: document.querySelector("#musicTitle"),
musicArtist: document.querySelector("#musicArtist"),
musicVolume: document.querySelector("#musicVolume"),
musicLight: document.querySelector("#musicLight"),
musicDark: document.querySelector("#musicDark"),
oceanWaves: document.querySelector("#oceanWaves"),
oceanWaveImage: document.querySelector("#oceanWaveImage"),
landTexture: document.querySelector("#landTexture"),
landTextureImage: document.querySelector("#landTextureImage"),
};
const mapData = {
worldFeatures: [],
chinaFeatures: [],
cities: [],
cityCatalog: [],
books: [],
specials: [],
specialBoundaries: new Map(),
specialContexts: new Map(),
};
let specialDataLoadPromise = null;
const specialDataLoadPromises = new Map();
const DATA_VERSION = "20260714-predeploy-010";
const SHARE_HALFTONE_REVEAL_WAIT_MS = 1100;
const SHARE_HALFTONE_CACHE_LIMIT = 10;
const SHARE_HALFTONE_PREWARM_TIMEOUT_MS = 350;
const SHARE_HALFTONE_RENDER_CONCURRENCY = 1;
const SHARE_HALFTONE_RENDER_SCALE = 1.5;
const SHARE_HALFTONE_RADIUS_BUCKETS = 28;
const SHARE_HALFTONE_RENDER_YIELD_ROWS = 12;
const SHARE_HALFTONE_RENDER_YIELD_BUCKETS = 8;
const BOOK_COVER_PRELOAD_LIMIT = 96;
const BOOK_COVER_PRELOAD_NEIGHBORS = 2;
const SHARE_HALFTONE_PRIORITY = {
prewarm: 1,
immediate: 2,
visible: 3,
};
const SHARE_HALFTONE_PREWARM_NEIGHBORS = 1;
const SPECIAL_CITY_GLOBAL_BOOK_LIMIT = 2;
const SPECIAL_CITY_IDS = new Set(SPECIAL_DEFINITIONS.map((special) => special.cityId));
const cityMarkerCache = new Map();
const specialLabelCache = new Map();
const bookCoverPreloadCache = new Map();
const shareHalftoneCache = new Map();
const shareHalftoneRenderQueue = [];
let shareHalftoneActiveRenders = 0;
const shareHalftoneActiveJobs = new Set();
async function init() {
const initParams = new URLSearchParams(window.location.search);
const shareSnapshotBookId = initParams.get("shareTicketBookId");
if (shareSnapshotBookId) {
await initShareSnapshot(shareSnapshotBookId);
return;
}
setupHalftoneCanvas();
await initAnalytics();
const [world, cityDb, pointDb, bookDb] = await Promise.all([
fetchJson("public/maps/world/countries-110m.geojson").catch(() => fetchJson("public/maps/world/countries-50m.geojson")),
fetchJson("public/data/world-citybook/cities.json").catch(() => ({ cities: [] })),
fetchJson("public/data/world-citybook/citybook-points.json").catch(() => ({ points: [] })),
fetchJson("public/data/world-citybook/books.json").catch(() => ({ books: [] })),
]);
mapData.worldFeatures = prepareWorldFeatures(world);
mapData.cities = normalizeCities(pointDb.points).filter((point) => pointBooks(point).length);
mapData.cityCatalog = normalizeCities(cityDb.cities);
mapData.books = normalizeBookRecords(bookDb.books);
mapData.specials = buildSpecials();
state.search.index = buildSearchIndex();
loadSavedBooks();
setupMapStyleControl();
warmCitySpecialOceanTexture();
render();
wireEvents();
setupEngagementTracking();
}
async function initShareSnapshot(bookId) {
document.documentElement.dataset.shareSnapshotReady = "loading";
try {
const params = new URLSearchParams(window.location.search);
const theme = params.get("theme");
if (theme === "light" || theme === "dark") {
state.theme = theme;
document.documentElement.dataset.theme = state.theme;
}
const [cityDb, pointDb, bookDb] = await Promise.all([
fetchJson("public/data/world-citybook/cities.json").catch(() => ({ cities: [] })),
fetchJson("public/data/world-citybook/citybook-points.json").catch(() => ({ points: [] })),
fetchJson("public/data/world-citybook/books.json").catch(() => ({ books: [] })),
]);
mapData.cities = normalizeCities(pointDb.points).filter((point) => pointBooks(point).length);
mapData.cityCatalog = normalizeCities(cityDb.cities);
mapData.books = normalizeBookRecords(bookDb.books);
mapData.specials = buildSpecials();
const isExportSnapshot = params.get("shareSnapshot") === "1";
await openShareSnapshotBook(bookId, {
renderMap: false,
halftoneMaxWait: isExportSnapshot ? 10000 : 1800,
});
} catch (error) {
console.error("Failed to render share snapshot", error);
document.documentElement.dataset.shareSnapshotError = error.message || "Failed to render share snapshot";
document.documentElement.dataset.shareSnapshotReady = "failed";
}
}
function wireEvents() {
elements.themeToggle.addEventListener("click", () => {
playUiSound("radioControl");
state.theme = state.theme === "light" ? "dark" : "light";
applyTheme({ syncMusic: true });
trackEvent("theme_changed", {
theme: state.theme,
source_view: "theme_toggle",
});
});
elements.bookshelfToggle?.addEventListener("click", () => {
playUiSound("bookmarkOpen");
openBookshelf();
});
elements.bookshelfClose?.addEventListener("click", () => {
playUiSound("paperClose");
closeBookshelf();
});
elements.bookshelfPrev?.addEventListener("click", () => {
pageBookshelf(-1);
});
elements.bookshelfNext?.addEventListener("click", () => {
pageBookshelf(1);
});
elements.bookshelfGrid?.addEventListener("click", (event) => {
const item = event.target.closest(".bookshelf-book[data-book-id]");
if (!item) return;
openShelfBook(item.dataset.bookId);
});
elements.bookshelfGrid?.addEventListener("pointerover", syncBookshelfHoverFromPointer);
elements.bookshelfGrid?.addEventListener("mouseover", syncBookshelfHoverFromPointer);
elements.bookshelfGrid?.addEventListener("pointermove", syncBookshelfHoverFromPointer);
elements.bookshelfGrid?.addEventListener("mousemove", syncBookshelfHoverFromPointer);
elements.bookshelfGrid?.addEventListener("pointerleave", clearBookshelfHover);
elements.bookshelfGrid?.addEventListener("mouseleave", clearBookshelfHover);
elements.bookshelfView?.addEventListener("pointerover", syncBookshelfHoverFromPointer);
elements.bookshelfView?.addEventListener("mouseover", syncBookshelfHoverFromPointer);
elements.bookshelfView?.addEventListener("pointermove", syncBookshelfHoverFromPointer);
elements.bookshelfView?.addEventListener("mousemove", syncBookshelfHoverFromPointer);
elements.bookshelfView?.addEventListener("pointerleave", clearBookshelfHover);
elements.bookshelfView?.addEventListener("mouseleave", clearBookshelfHover);
setupBookshelfHoverObserver();
elements.searchCapsule?.addEventListener("submit", (event) => {
event.preventDefault();
activateSearchResult(state.search.activeIndex >= 0 ? state.search.activeIndex : 0);
});
elements.searchInput?.addEventListener("input", () => {
hideSearchSuggestion();
updateSearchQuery(elements.searchInput.value);
});
elements.searchInput?.addEventListener("focus", () => {
updateSearchQuery(elements.searchInput.value, { openIfEmpty: false });
scheduleSearchSuggestion();
});
elements.searchInput?.addEventListener("keydown", handleSearchKeydown);
elements.searchSuggestion?.addEventListener("pointerdown", (event) => {
event.preventDefault();
});
elements.searchSuggestion?.addEventListener("click", () => {
confirmSearchSuggestion();
});
elements.searchResults?.addEventListener("pointerdown", (event) => {
event.preventDefault();
});
elements.searchResults?.addEventListener("pointerover", syncSearchActiveFromPointer);
elements.searchResults?.addEventListener("pointermove", syncSearchActiveFromPointer);
elements.searchResults?.addEventListener("mouseover", syncSearchActiveFromPointer);
elements.searchResults?.addEventListener("click", (event) => {
const option = event.target.closest(".search-result[data-result-index]");
if (!option) return;
activateSearchResult(Number(option.dataset.resultIndex));
});
document.addEventListener("pointerdown", (event) => {
if (!elements.searchCapsule?.contains(event.target)) {
closeSearchResults();
hideSearchSuggestion();
}
});
elements.searchClear?.addEventListener("click", () => {
playUiSound("softTap");
elements.searchInput.value = "";
elements.searchInput.focus();
hideSearchSuggestion();
updateSearchQuery("");
scheduleSearchSuggestion();
});
elements.specialBackButton.addEventListener("click", () => {
exitSpecialView({ playSound: true });
});
elements.mapSvg.addEventListener("pointerdown", (event) => {
if (state.activeSpecialId) return;
elements.mapSvg.setPointerCapture(event.pointerId);
state.dragging = {
x: event.clientX,
y: event.clientY,
center: [...state.center],
startedOnCityPoint: Boolean(event.target.closest(".city-html-marker, .special-capsule")),
renderFrame: null,
};
elements.mapSvg.classList.add("is-dragging");
});
elements.mapSvg.addEventListener("pointermove", (event) => {
if (!state.dragging) return;
const dx = event.clientX - state.dragging.x;
const dy = event.clientY - state.dragging.y;
state.dragging.moved = state.dragging.moved || Math.abs(dx) > 4 || Math.abs(dy) > 4;
const sensitivity = 0.28 / state.zoom;
state.center = [
normalizeLongitude(state.dragging.center[0] - dx * sensitivity),
clamp(state.dragging.center[1] + dy * sensitivity, -72, 72),
];
scheduleMapDragRender();
});
elements.mapSvg.addEventListener("pointerup", endDrag);
elements.mapSvg.addEventListener("pointercancel", endDrag);
window.addEventListener("keydown", (event) => {
if (event.key === "0") {
exitSpecialView({ playSound: true });
} else if (event.key === "Escape") {
if (state.bookshelfOpen) {
closeBookshelf();
} else if (elements.sharePreview && !elements.sharePreview.hidden) {
closeSharePreview({ reason: "escape" });
} else if (state.activePoint && elements.cityTooltip && !elements.cityTooltip.hidden) {
handleTooltipClose();
} else if (state.activeSpecialId) {
exitSpecialView({ playSound: true });
}
}
});
elements.cityTooltip.addEventListener("click", (event) => {
const target = event.target.closest("button");
const ticket = event.target.closest(".book-ticket[data-book-index]");
if (ticket && !target) {
if (shouldKeepTooltipTextSelection(event)) return;
selectBookTicket(ticket, { rerender: !target });
}
if (!target) return;
if (target.classList.contains("book-card__save")) {
toggleActiveBookSave();
} else if (target.classList.contains("book-card__detail")) {
playUiSound("bookmarkOpen");
enterBookDetailMode();
} else if (target.classList.contains("book-card__share")) {
playUiSound("softTap");
openSharePreview();
} else if (target.classList.contains("book-card__city-page--prev")) {
pageActiveCity(-1);
} else if (target.classList.contains("book-card__city-page--next")) {
pageActiveCity(1);
} else if (target.classList.contains("book-card__page--prev")) {
pageActiveBook(-1);
} else if (target.classList.contains("book-card__page--next")) {
pageActiveBook(1);
} else if (target.classList.contains("book-card__close")) {
playUiSound("paperClose");
handleTooltipClose();
}
});
elements.cityTooltip.addEventListener("pointerdown", (event) => {
state.tooltipPointer = {
x: event.clientX,
y: event.clientY,
};
});
elements.cityTooltip.addEventListener("pointercancel", () => {
state.tooltipPointer = null;
});
elements.cityTooltip.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const ticket = event.target.closest(".book-ticket[data-book-index]");
if (!ticket) return;
event.preventDefault();
selectBookTicket(ticket, { rerender: true });
});
elements.sharePreview?.addEventListener("click", (event) => {
const target = event.target.closest("button");
if (!target) return;
if (target.classList.contains("share-preview__close")) {
playUiSound("paperClose");
closeSharePreview({ reason: "button" });
} else if (target.classList.contains("share-preview__save")) {
saveShareTicketImage().catch((error) => {
console.error("Failed to save share ticket", error);
});
} else if (target.classList.contains("share-preview__page--prev")) {
pageSharePreview(-1);
} else if (target.classList.contains("share-preview__page--next")) {
pageSharePreview(1);
}
});
elements.sharePreview?.addEventListener("pointerover", (event) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest(".share-ticket, .share-preview__save, .share-preview__close")) revealSharePreviewControls();
});
elements.sharePreview?.addEventListener("pointermove", (event) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest(".share-ticket, .share-preview__save, .share-preview__close")) revealSharePreviewControls();
else if (elements.sharePreview.classList.contains("has-share-controls")) scheduleSharePreviewControlsHide();
});
elements.sharePreview?.addEventListener("pointerout", (event) => {
const target = event.target instanceof Element ? event.target : null;
const next = event.relatedTarget instanceof Element ? event.relatedTarget : null;
const nextIsControl = Boolean(next?.closest(".share-ticket, .share-preview__save, .share-preview__close"));
const leavingInteractiveSurface = Boolean(target?.closest(".share-ticket, .share-preview__save, .share-preview__close")) && !nextIsControl;
if (!next || !elements.sharePreview.contains(next) || leavingInteractiveSurface) scheduleSharePreviewControlsHide();
});
window.addEventListener("resize", () => {
scheduleTicketCutPaths();
renderBookshelf();
syncSearchResultsBounds();
}, { passive: true });
window.visualViewport?.addEventListener("resize", syncSearchResultsBounds, { passive: true });
setupHeroLineWidth();
setupMusicSignature();
setupAssetLoadFailureTracking();
startSubtleDrift();
}
function setupEngagementTracking() {
if (state.analytics.engagementTimers.length) return;
state.analytics.engagementTimers = ENGAGEMENT_THRESHOLDS_SECONDS.map((seconds) => setTimeout(() => {
if (state.analytics.engagementMarks.has(seconds)) return;
state.analytics.engagementMarks.add(seconds);
trackEvent("session_engaged", {
duration_seconds: seconds,
});
}, seconds * 1000));
}
function setupAssetLoadFailureTracking() {
if (state.analytics.assetLoadTrackingReady) return;
state.analytics.assetLoadTrackingReady = true;
window.addEventListener("error", (event) => {
const target = event.target;
const tagName = String(target?.tagName || "").toLowerCase();
if (!["img", "audio", "source", "video"].includes(tagName)) return;
const src = target.currentSrc || target.src || target.getAttribute?.("src") || "";
trackEvent("asset_load_failed", {
tag_name: tagName,
asset_path: analyticsAssetPath(src),
});
}, true);
}
function setupMapStyleControl() {
const styles = normalizeCityTileStyles(runtimeCityTileConfig());
const runtimeConfig = runtimeCityTileConfig();
state.activeMapStyleId = runtimeConfig.activeMapStyleId
|| runtimeConfig.defaultMapStyleId
|| runtimeConfig.defaultMapboxStyleKey
|| styles[0]?.id
|| "light";
state.customMapboxStyleId = runtimeConfig.customMapboxStyleId
|| runtimeConfig.mapboxStyleId
|| runtimeConfig.mapbox?.styleId
|| "";
if (!styles.some((style) => style.id === state.activeMapStyleId)) {
state.activeMapStyleId = styles[0]?.id || "light";
}
}
function currentTileRuntimeConfig() {
return {
...runtimeCityTileConfig(),
activeMapStyleId: state.activeMapStyleId,
customMapboxStyleId: state.customMapboxStyleId,
};
}
function setupHeroLineWidth() {
syncHeroLineWidth();
document.fonts?.ready.then(syncHeroLineWidth).catch(() => {});
window.addEventListener("resize", syncHeroLineWidth, { passive: true });
}
function syncHeroLineWidth() {
if (!elements.heroCopy || !elements.heroTagline) return;
const width = elements.heroTagline.getBoundingClientRect().width;
if (!Number.isFinite(width) || width <= 0) return;
elements.heroCopy.style.setProperty("--hero-line-width", `${width.toFixed(2)}px`);
}
function audioContext() {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return null;
if (!state.audio.context) state.audio.context = new AudioContextClass();
return state.audio.context;
}
function withAudioContext(callback) {
const context = audioContext();
if (!context) return;
if (context.state === "suspended") {
context.resume().catch(() => {});
}
callback(context);
}
function playUiSound(cue) {
switch (cue) {
case "softTap":
playSoftTapSound();
break;
case "pageFlick":
playPageFlickSound();
break;
case "bookmarkOpen":
playBookmarkOpenSound();
break;
case "paperClose":
playPaperCloseSound();
break;
case "saveStamp":
playSaveStampSound();
break;
case "coinCollect":
playCoinCollectSound();
break;
case "cityMapLoading":
playCityMapLoadingSound();
break;
case "radioControl":
playRadioControlSound();
break;
default:
break;
}
}
function playSoftTapSound() {
withAudioContext((context) => {
const now = context.currentTime;
playPageNoiseLayer(context, now, 0.055, 1250, 760, 0.009, 1.14);
playToneLayer(context, now + 0.006, "triangle", 214, 184, 0.07, 0.006);
});
}
function playPageFlickSound() {
withAudioContext((context) => {
const now = context.currentTime;
playPageNoiseLayer(context, now, 0.15, 2100, 680, 0.022, 1.1);
playPageNoiseLayer(context, now + 0.04, 0.105, 1160, 420, 0.014, 0.86);
playToneLayer(context, now + 0.1, "triangle", 116, 76, 0.11, 0.009);
});
}
function playBookmarkOpenSound() {
withAudioContext((context) => {
const now = context.currentTime;
playPageNoiseLayer(context, now, 0.24, 1620, 520, 0.029, 0.76);
playPageNoiseLayer(context, now + 0.072, 0.17, 2450, 820, 0.018, 1.04);
playToneLayer(context, now + 0.15, "triangle", 132, 92, 0.17, 0.014);
});
}
function playPaperCloseSound() {
withAudioContext((context) => {
const now = context.currentTime;
playPageNoiseLayer(context, now, 0.11, 940, 340, 0.016, 0.82);
playToneLayer(context, now + 0.025, "triangle", 96, 68, 0.12, 0.012);
});
}
function playSaveStampSound() {
withAudioContext((context) => {
const now = context.currentTime;
playPageNoiseLayer(context, now, 0.08, 1420, 620, 0.014, 1);
playToneLayer(context, now + 0.025, "square", 154, 112, 0.095, 0.012);
playToneLayer(context, now + 0.052, "triangle", 308, 246, 0.105, 0.006);
});
}
function playCoinCollectSound() {
withAudioContext((context) => {
const now = context.currentTime;
playToneLayer(context, now, "triangle", 784, 1047, 0.064, 0.018);
playToneLayer(context, now + 0.05, "square", 1047, 1568, 0.076, 0.014);
playToneLayer(context, now + 0.012, "sine", 1568, 2093, 0.052, 0.0045);
playToneLayer(context, now + 0.07, "triangle", 392, 523, 0.105, 0.006);
});
}
function playCityMapLoadingSound() {
withAudioContext((context) => {
const now = context.currentTime;
stopCityMapLoadingSound({ fadeDuration: 0.08, finalPulses: 0 });
startCityMapLoadingLoop(context, now);
});
}
function playCityMapLoadingIntroSound(context, now = context.currentTime, destination = context.destination) {
playPageNoiseLayer(context, now, 0.18, 980, 520, 0.01, 0.82, destination);
playToneLayer(context, now + 0.02, "sine", 196, 246, 0.13, 0.012, destination);
playToneLayer(context, now + 0.13, "triangle", 246, 329, 0.15, 0.011, destination);
playToneLayer(context, now + 0.28, "sine", 329, 392, 0.16, 0.009, destination);
}
function startCityMapLoadingLoop(context, startAt = context.currentTime) {
const masterGain = context.createGain();
masterGain.gain.setValueAtTime(0.0001, startAt);
masterGain.gain.exponentialRampToValueAtTime(1, startAt + 0.18);
masterGain.connect(context.destination);
const loading = {
context,
masterGain,
stopping: false,
stopped: false,
repeatTimer: null,
stopTimer: null,
};
state.audio.cityMapLoading = loading;
scheduleCityMapLoadingRepeat(loading, Math.max(0, (startAt - context.currentTime) * 1000));
}
function scheduleCityMapLoadingRepeat(loading, delayMs = 0) {
if (!loading || loading.stopped) return;
loading.repeatTimer = window.setTimeout(() => {
if (loading.stopped || loading.stopping) return;
playCityMapLoadingIntroSound(loading.context, loading.context.currentTime, loading.masterGain);
scheduleCityMapLoadingRepeat(loading, 560);
}, delayMs);
}
function stopCityMapLoadingSound(options = {}) {
const loading = state.audio.cityMapLoading;
if (!loading) return;
loading.stopping = true;
if (loading.repeatTimer) window.clearTimeout(loading.repeatTimer);
if (loading.stopTimer) window.clearTimeout(loading.stopTimer);
const fadeDuration = Math.max(0.04, options.fadeDuration ?? 0.9);
const context = loading.context;
const now = context.currentTime;
loading.masterGain.gain.cancelScheduledValues(now);
loading.masterGain.gain.setValueAtTime(Math.max(loading.masterGain.gain.value, 0.0001), now);
loading.masterGain.gain.exponentialRampToValueAtTime(0.0001, now + fadeDuration);
loading.stopTimer = window.setTimeout(() => {
loading.stopped = true;
if (loading.repeatTimer) window.clearTimeout(loading.repeatTimer);
loading.masterGain.disconnect();
if (state.audio.cityMapLoading === loading) state.audio.cityMapLoading = null;
}, Math.ceil((fadeDuration + 0.04) * 1000));
}
function playRadioControlSound() {
withAudioContext((context) => {
const now = context.currentTime;
playToneLayer(context, now, "sine", 174, 220, 0.105, 0.018);
playToneLayer(context, now + 0.012, "triangle", 520, 610, 0.09, 0.007);
});
}
function playToneLayer(context, startAt, type, startFrequency, endFrequency, duration, peakGain, destination = context.destination) {
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(startFrequency, startAt);
oscillator.frequency.exponentialRampToValueAtTime(endFrequency, startAt + duration);
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(peakGain, startAt + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
oscillator.connect(gain).connect(destination);
oscillator.start(startAt);
oscillator.stop(startAt + duration + 0.02);
}
function playGlowSound() {
playUiSound("radioControl");
}
function setupMusicSignature() {
if (!elements.musicSignature || !elements.musicToggle) return;
loadMusicPrefs();
syncMusicTrackForTheme({ force: true });
applyMusicVolumeToAll();
updateMusicSignature();
elements.musicToggle.addEventListener("pointerdown", beginMusicControlDrag);
elements.musicToggle.addEventListener("pointermove", moveMusicControlDrag);
elements.musicToggle.addEventListener("pointerup", endMusicControlDrag);
elements.musicToggle.addEventListener("pointercancel", cancelMusicControlDrag);
elements.musicToggle.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
toggleMusicPlayback();
});
}
function loadMusicPrefs() {
try {
const prefs = JSON.parse(localStorage.getItem(MUSIC_PREFS_KEY) || "{}");
if (Number.isFinite(prefs.volume)) {
state.audio.musicVolume = clamp(prefs.volume, 0, 1);
}
if (typeof prefs.muted === "boolean") {
state.audio.musicMuted = prefs.muted;
}
} catch (error) {
state.audio.musicVolume = DEFAULT_MUSIC_VOLUME;
state.audio.musicMuted = false;
}
}
function saveMusicPrefs() {
try {
localStorage.setItem(MUSIC_PREFS_KEY, JSON.stringify({
volume: state.audio.musicVolume,
muted: state.audio.musicMuted,
}));
} catch (error) {
// Music preferences are decorative; storage failures should never block the map.
}
}
function beginMusicControlDrag(event) {
if (event.button !== 0) return;
event.preventDefault();
elements.musicToggle.setPointerCapture(event.pointerId);
state.audio.musicDrag = {
pointerId: event.pointerId,
startY: event.clientY,
startVolume: state.audio.musicVolume,
moved: false,
};
}
function moveMusicControlDrag(event) {
const drag = state.audio.musicDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
const deltaY = drag.startY - event.clientY;
if (!drag.moved && Math.abs(deltaY) < 8) return;
drag.moved = true;
updateMusicVolumeFromDrag(event.clientY);
}
function endMusicControlDrag(event) {
const drag = state.audio.musicDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
if (elements.musicToggle.hasPointerCapture(event.pointerId)) {
elements.musicToggle.releasePointerCapture(event.pointerId);
}
state.audio.musicDrag = null;
if (drag.moved) {
playUiSound("radioControl");
saveMusicPrefs();
scheduleMusicVolumeHide();
trackEvent("music_volume_changed", {
source_view: "music_control",
track: state.audio.musicTrack,
volume_percent: Math.round(state.audio.musicVolume * 100),
muted: state.audio.musicMuted,
});
} else {
toggleMusicPlayback();
}
}
function cancelMusicControlDrag(event) {
const drag = state.audio.musicDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
if (elements.musicToggle.hasPointerCapture(event.pointerId)) {
elements.musicToggle.releasePointerCapture(event.pointerId);
}
state.audio.musicDrag = null;
scheduleMusicVolumeHide();
}
function updateMusicVolumeFromDrag(clientY) {
const drag = state.audio.musicDrag;
if (!drag) return;
const nextVolume = clamp(drag.startVolume + ((drag.startY - clientY) / 150), 0, 1);
state.audio.musicVolume = nextVolume;
state.audio.musicMuted = nextVolume <= 0.01;
if (nextVolume > 0.01 && !state.audio.musicPlaying) {
startMusicPlayback();
}
applyMusicVolumeToAll();
showMusicVolume();
updateMusicSignature();
}
function toggleMusicPlayback() {
playUiSound("radioControl");
if (!state.audio.musicPlaying) {
state.audio.musicMuted = false;
startMusicPlayback();
} else {
state.audio.musicMuted = !state.audio.musicMuted;
applyMusicVolumeToAll();
updateMusicSignature();
}
saveMusicPrefs();
trackEvent(state.audio.musicMuted ? "music_paused" : "music_played", {
source_view: "music_control",
track: state.audio.musicTrack,
volume_percent: Math.round(state.audio.musicVolume * 100),
muted: state.audio.musicMuted,
});
}
function startMusicPlayback() {
const audio = activeMusicAudio();
if (!audio) return;
state.audio.musicPlaying = true;
state.audio.musicMuted = state.audio.musicVolume <= 0.01 ? true : state.audio.musicMuted;
applyMusicVolumeToAll();
audio.play().then(updateMusicSignature).catch(() => {
state.audio.musicPlaying = false;
updateMusicSignature();
});
}
function activeMusicAudio() {
return state.audio.musicTrack === "dark" ? elements.musicDark : elements.musicLight;
}
function inactiveMusicAudio() {
return state.audio.musicTrack === "dark" ? elements.musicLight : elements.musicDark;
}
function effectiveMusicVolume() {
return state.audio.musicMuted ? 0 : state.audio.musicVolume;
}
function applyMusicVolumeToAll() {
const volume = effectiveMusicVolume();
[elements.musicLight, elements.musicDark].forEach((audio) => {
if (audio) audio.volume = volume;
});
elements.musicSignature?.style.setProperty("--music-level", String(state.audio.musicVolume));
}
function syncMusicTrackForTheme(options = {}) {
if (!elements.musicLight || !elements.musicDark) return;
const nextTrack = state.theme === "dark" ? "dark" : "light";
const previousAudio = activeMusicAudio();
const trackChanged = nextTrack !== state.audio.musicTrack;
state.audio.musicTrack = nextTrack;
const nextAudio = activeMusicAudio();
const inactiveAudio = inactiveMusicAudio();
if (inactiveAudio && inactiveAudio !== nextAudio && !trackChanged) {
inactiveAudio.pause();
}
if (trackChanged && state.audio.musicPlaying) {
switchMusicTrack(previousAudio, nextAudio, Boolean(options.syncMusic));
} else {
applyMusicVolumeToAll();
}
updateMusicSignature();
}
function switchMusicTrack(previousAudio, nextAudio, withFade) {
if (!nextAudio || previousAudio === nextAudio) return;
if (state.audio.musicFadeFrame) cancelAnimationFrame(state.audio.musicFadeFrame);
const targetVolume = effectiveMusicVolume();
nextAudio.volume = withFade ? 0 : targetVolume;
nextAudio.play().catch(() => {
state.audio.musicPlaying = false;
updateMusicSignature();
});
if (!withFade || !previousAudio) {
previousAudio?.pause();
if (previousAudio) previousAudio.volume = targetVolume;
nextAudio.volume = targetVolume;
return;
}
const startedAt = performance.now();
const duration = 520;
const step = (timestamp) => {
const progress = clamp((timestamp - startedAt) / duration, 0, 1);
previousAudio.volume = targetVolume * (1 - progress);
nextAudio.volume = targetVolume * progress;
if (progress < 1) {
state.audio.musicFadeFrame = requestAnimationFrame(step);
return;
}
previousAudio.pause();
previousAudio.volume = targetVolume;
nextAudio.volume = targetVolume;
state.audio.musicFadeFrame = null;
};
state.audio.musicFadeFrame = requestAnimationFrame(step);
}
function updateMusicSignature() {
if (!elements.musicSignature) return;
const track = MUSIC_TRACKS[state.audio.musicTrack] || MUSIC_TRACKS.light;
if (elements.musicTitle) elements.musicTitle.textContent = track.title;
if (elements.musicArtist) elements.musicArtist.textContent = track.artist;
if (elements.musicVolume) elements.musicVolume.textContent = `${Math.round(state.audio.musicVolume * 100)}%`;
const mutedOrPaused = state.audio.musicMuted || !state.audio.musicPlaying;
elements.musicSignature.classList.toggle("is-playing", state.audio.musicPlaying && !state.audio.musicMuted);
elements.musicSignature.classList.toggle("is-muted", mutedOrPaused);
elements.musicToggle?.classList.toggle("is-muted", mutedOrPaused);
elements.musicToggle?.setAttribute("aria-label", musicToggleLabel());
}
function musicToggleLabel() {
if (!state.audio.musicPlaying) return "播放背景音乐,长按上下拖动调节音量";
if (state.audio.musicMuted) return "开启背景音乐,长按上下拖动调节音量";
return "静音背景音乐,长按上下拖动调节音量";
}
function showMusicVolume() {
if (!elements.musicSignature) return;
window.clearTimeout(state.audio.musicVolumeTimer);
elements.musicSignature.classList.add("is-adjusting");
}
function scheduleMusicVolumeHide() {
if (!elements.musicSignature) return;
window.clearTimeout(state.audio.musicVolumeTimer);
state.audio.musicVolumeTimer = window.setTimeout(() => {
elements.musicSignature.classList.remove("is-adjusting");
}, 760);
}
function playPageSound() {
playUiSound("pageFlick");
}
function playPageNoiseLayer(context, startAt, duration, startFrequency, endFrequency, peakGain, playbackRate, destination = context.destination) {
const sampleRate = context.sampleRate;
const frameCount = Math.max(1, Math.floor(sampleRate * duration));
const buffer = context.createBuffer(1, frameCount, sampleRate);
const samples = buffer.getChannelData(0);
let previous = 0;
for (let index = 0; index < frameCount; index += 1) {
const progress = index / frameCount;
const attack = Math.min(progress / 0.08, 1);
const release = Math.pow(1 - progress, 1.55);
const white = Math.random() * 2 - 1;
previous = previous * 0.72 + white * 0.28;
samples[index] = previous * attack * release;
}
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const gain = context.createGain();
source.buffer = buffer;
source.playbackRate.setValueAtTime(playbackRate, startAt);
filter.type = "bandpass";
filter.frequency.setValueAtTime(startFrequency, startAt);
filter.frequency.exponentialRampToValueAtTime(endFrequency, startAt + duration);
filter.Q.setValueAtTime(0.64, startAt);
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(peakGain, startAt + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
source.connect(filter).connect(gain).connect(destination);
source.start(startAt);
source.stop(startAt + duration + 0.02);
}
function playPageSettleLayer(context, startAt) {
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = "triangle";
oscillator.frequency.setValueAtTime(118, startAt);
oscillator.frequency.exponentialRampToValueAtTime(74, startAt + 0.11);
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(0.012, startAt + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + 0.13);
oscillator.connect(gain).connect(context.destination);
oscillator.start(startAt);
oscillator.stop(startAt + 0.15);
}
function render(options = {}) {
const projection = createProjection();
renderCityTileLayer(projection);
renderGlobe(projection, options);
renderWorld(projection, options);
elements.highlightLayer.replaceChildren();
renderSpecialBoundary(projection, options);
renderCities(projection, options);
renderSpecialLabels(projection);
renderSpecialBooks(projection);
syncSpecialBackButton();
renderBookshelf();
syncTooltip(projection, options);
}
function applyTheme(options = {}) {
document.documentElement.dataset.theme = state.theme;
elements.themeToggle.setAttribute("aria-pressed", String(state.theme === "dark"));
elements.themeToggle.setAttribute("aria-label", state.theme === "dark" ? "切换亮色模式" : "切换暗色模式");
syncGlobeTextures(createProjection());
warmCitySpecialOceanTexture();
if (state.halftone.initialized) drawHalftoneFrame(performance.now(), true);
syncMusicTrackForTheme(options);
}
function setupHalftoneCanvas() {
if (!elements.halftoneCanvas || state.halftone.reducedMotion) return;
if (state.halftone.initialized) return;
state.halftone.initialized = true;
state.halftone.context = elements.halftoneCanvas.getContext("2d", { alpha: true });
resizeHalftoneCanvas();
state.halftone.pointerX = state.halftone.width * 0.28;
state.halftone.pointerY = state.halftone.height * 0.48;
state.halftone.targetX = state.halftone.pointerX;
state.halftone.targetY = state.halftone.pointerY;
drawHalftoneFrame(performance.now(), true);
document.documentElement.classList.add("is-halftone-ready");
elements.mapStage.addEventListener("pointermove", (event) => {
const rect = elements.mapStage.getBoundingClientRect();
state.halftone.targetX = event.clientX - rect.left;
state.halftone.targetY = event.clientY - rect.top;
state.halftone.pulse = 1;
state.halftone.activeUntil = performance.now() + HALFTONE_ACTIVE_AFTER_POINTER_MS;
scheduleHalftoneFrame();
}, { passive: true });
elements.mapStage.addEventListener("pointerleave", () => {
state.halftone.pulse = Math.min(state.halftone.pulse, 0.42);
scheduleHalftoneFrame();
}, { passive: true });
window.addEventListener("resize", () => {
scheduleHalftoneResize();
}, { passive: true });
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
state.halftone.activeUntil = performance.now() + HALFTONE_ACTIVE_AFTER_POINTER_MS;
scheduleHalftoneFrame();
}
});
}
function resizeHalftoneCanvas() {
const canvas = elements.halftoneCanvas;
if (!canvas) return false;
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
if (canvas.width === Math.round(width * dpr) && canvas.height === Math.round(height * dpr)) return false;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
state.halftone.width = width;
state.halftone.height = height;
state.halftone.dpr = dpr;
state.halftone.context?.setTransform(dpr, 0, 0, dpr, 0, 0);
return true;
}
function scheduleHalftoneResize() {
if (state.halftone.resizeTimer) window.clearTimeout(state.halftone.resizeTimer);
state.halftone.resizeTimer = window.setTimeout(() => {
state.halftone.resizeTimer = null;
if (resizeHalftoneCanvas()) drawHalftoneFrame(performance.now(), true);
}, 80);
}
function scheduleHalftoneFrame() {
if (state.halftone.frame || state.halftone.reducedMotion) return;
state.halftone.frame = requestAnimationFrame((time) => {
state.halftone.frame = null;
drawHalftoneFrame(time);
});
}
function drawHalftoneFrame(time, force = false) {
const context = state.halftone.context;
if (!context || state.halftone.reducedMotion) return;
if (document.hidden) {
state.halftone.lastDrawAt = 0;
return;
}
const { width, height } = state.halftone;
if (!width || !height) return;
if (state.activeSpecialId) {
if (force || state.halftone.lastDrawAt) context.clearRect(0, 0, width, height);
state.halftone.lastDrawAt = 0;
return;
}
const pointerDelta = Math.hypot(
state.halftone.targetX - state.halftone.pointerX,
state.halftone.targetY - state.halftone.pointerY,
);
const isActive = time <= state.halftone.activeUntil
|| state.halftone.pulse > 0.02
|| pointerDelta > HALFTONE_SETTLE_EPSILON;
const frameMs = HALFTONE_ACTIVE_FRAME_MS;
if (!force && state.halftone.lastDrawAt && time - state.halftone.lastDrawAt < frameMs) {
if (isActive) scheduleHalftoneFrame();
return;
}
state.halftone.lastDrawAt = time;
context.clearRect(0, 0, width, height);
state.halftone.pointerX += (state.halftone.targetX - state.halftone.pointerX) * 0.14;
state.halftone.pointerY += (state.halftone.targetY - state.halftone.pointerY) * 0.14;
const pointerX = state.halftone.pointerX || width * 0.34;
const pointerY = state.halftone.pointerY || height * 0.45;
const pulse = state.halftone.pulse;
const radius = Math.min(width, height) * 0.25;
const radius2 = radius * radius;
const dotColor = [255, 255, 255];
const ambientDriftX = Math.sin(time * 0.00022) * width * 0.14;
const ambientDriftY = Math.cos(time * 0.00018) * height * 0.1;
const ambientFocusX = width * 0.24 + ambientDriftX;
const ambientFocusY = height * 0.52 + ambientDriftY;
const tide = 0.5 + 0.5 * Math.sin(time * 0.00062);
for (let y = -HALFTONE_STEP; y <= height + HALFTONE_STEP; y += HALFTONE_STEP) {
const rowOffset = Math.round(y / HALFTONE_STEP) % 2 ? HALFTONE_STEP * 0.48 : 0;
for (let x = -HALFTONE_STEP; x <= width + HALFTONE_STEP; x += HALFTONE_STEP) {
const px = x + rowOffset;
const dx = px - pointerX;
const dy = y - pointerY;
const distance2 = dx * dx + dy * dy;
const mouseInfluence = Math.max(0, 1 - distance2 / radius2) ** 2 * pulse;
const ambientDx = px - ambientFocusX;
const ambientDy = y - ambientFocusY;
const ambientDistance = Math.hypot(ambientDx, ambientDy);
const ambientWave = 0.5 + 0.5 * Math.sin(ambientDistance * 0.024 - time * 0.0017);
const flowWave = 0.5 + 0.5 * Math.sin(px * 0.016 + y * 0.028 + time * 0.00145 + Math.sin(y * 0.010 + time * 0.00042) * 2.5);
const eddyWave = 0.5 + 0.5 * Math.sin(px * 0.024 - y * 0.019 + time * 0.00165 + Math.sin((px + y) * 0.008 + time * 0.00086) * 3.1);
const mouseWave = 0.5 + 0.5 * Math.sin(Math.sqrt(distance2) * 0.052 - time * 0.0042);
const wave = (flowWave * 0.58 + eddyWave * 0.42) * (1 - mouseInfluence) + mouseWave * mouseInfluence;
const diagonal = 0.5 + 0.5 * Math.sin((px + y) * 0.018 - time * 0.00055);
const dotRadius = 0.58 + diagonal * 0.22 + ambientWave * 0.42 + wave * 0.62 + tide * 0.22 + mouseInfluence * 2.45;
const driftX = Math.sin(y * 0.038 + time * 0.00172 + eddyWave * 2.2) * (0.72 + wave * 0.82 + mouseInfluence * 1.2);
const driftY = Math.cos(px * 0.032 + time * 0.00134 + flowWave * 1.9) * (0.52 + ambientWave * 0.58 + mouseInfluence * 0.76);
context.beginPath();
context.fillStyle = `rgb(${dotColor[0]}, ${dotColor[1]}, ${dotColor[2]})`;
context.arc(px + driftX, y + driftY, dotRadius, 0, Math.PI * 2);
context.fill();
}
}
state.halftone.pulse *= 0.88;
const remainingDelta = Math.hypot(
state.halftone.targetX - state.halftone.pointerX,
state.halftone.targetY - state.halftone.pointerY,
);
if (time <= state.halftone.activeUntil || state.halftone.pulse > 0.02 || remainingDelta > HALFTONE_SETTLE_EPSILON) {
scheduleHalftoneFrame();
}
}
function renderGlobe(projection, options = {}) {
const fragment = document.createDocumentFragment();
syncGlobeTextures(projection);
fragment.append(
createSvg("circle", {
cx: projection.cx,
cy: projection.cy,
r: projection.radius,
class: "globe-ocean",
}),
);
fragment.append(
createSvg("circle", {
cx: projection.cx,
cy: projection.cy,
r: projection.radius,
class: "globe-wave",
}),
);
fragment.append(
createSvg("circle", {
cx: projection.cx,
cy: projection.cy,
r: Math.max(0, projection.radius - 3),
class: "globe-inner-softening",
}),
);
for (const path of graticulePaths(projection)) {
fragment.append(
createSvg("path", {
d: path,
class: "globe-graticule",
}),
);
}
fragment.append(
createSvg("circle", {
cx: projection.cx,
cy: projection.cy,
r: projection.radius,
class: "globe-rim",
}),
);
elements.globeLayer.replaceChildren(fragment);
}
function syncGlobeTextures(projection) {
const cityMap = activeSpecial()?.cityMap;
const landTile = cityMap ? 138 : (state.activeSpecialId ? 210 : 260);
const wrapLon = ((((projection.centerLon % 360) + 360) % 360) / 360);
const clampLat = Math.max(-75, Math.min(75, projection.centerLat));
const texturePhase = toRadians(projection.centerLon * 1.7 + projection.centerLat * 0.9);
const landX = cityMap ? 0 : -wrapLon * landTile * 0.9;
const landY = cityMap ? 0 : (clampLat / 180) * landTile * 0.38 + Math.sin(texturePhase) * 0.7;
const textureRotate = cityMap ? 0 : -projection.centerLon * 0.035;
const landTransform = `translate(${landX.toFixed(2)} ${landY.toFixed(2)}) rotate(${textureRotate.toFixed(2)})`;
elements.landTexture?.setAttribute("width", `${landTile}`);
elements.landTexture?.setAttribute("height", `${landTile}`);
elements.landTexture?.setAttribute("patternTransform", landTransform);
elements.landTextureImage?.setAttribute("width", `${landTile}`);
elements.landTextureImage?.setAttribute("height", `${landTile}`);
const oceanTile = state.activeSpecialId ? CITY_OCEAN_TEXTURE_TILE : GLOBAL_OCEAN_TEXTURE_TILE;
const oceanX = cityMap ? 0 : -wrapLon * oceanTile * 1.18;
const oceanY = cityMap ? 0 : (clampLat / 180) * oceanTile * 0.42 + Math.sin(texturePhase * 1.16) * 1.4;
const oceanRotate = cityMap ? -4 : -projection.centerLon * 0.045;
const oceanTransform = `translate(${oceanX.toFixed(2)} ${oceanY.toFixed(2)}) rotate(${oceanRotate.toFixed(2)})`;
const oceanTextureHref = citySpecialOceanTextureHref();
elements.oceanWaves?.setAttribute("width", `${oceanTile}`);
elements.oceanWaves?.setAttribute("height", `${oceanTile}`);
elements.oceanWaves?.setAttribute("patternTransform", oceanTransform);
elements.oceanWaveImage?.setAttribute("href", oceanTextureHref);
elements.oceanWaveImage?.setAttribute("width", `${oceanTile}`);
elements.oceanWaveImage?.setAttribute("height", `${oceanTile}`);
}
function citySpecialOceanTextureHref(theme = state.theme) {
return theme === "dark"
? "public/textures/ocean-wave-lines-white-alpha.png"
: "public/textures/ocean-wave-lines-alpha.png";
}
function renderWorld(projection, options = {}) {
const fragment = document.createDocumentFragment();
const special = activeSpecial();
const boundary = special ? mapData.specialBoundaries.get(special.cityId) : null;
const visualBoundaryFeatures = boundary?.visualFeatures || [];
const hasVisualBoundary = Boolean(visualBoundaryFeatures.length);
const useRegionalChina = Boolean(special?.city?.isoA3 === "CHN" && mapData.chinaFeatures.length && !hasVisualBoundary);
const features = useRegionalChina ? mapData.chinaFeatures : mapData.worldFeatures;
const landEntries = [];
for (const worldFeature of features) {
const path = featureEntryToPath(worldFeature, projection, options);
if (!path) continue;
if (special?.hiddenWorldIsoA3?.includes(featureIsoA3(worldFeature.feature))) continue;
const shade = useRegionalChina ? "land-strong" : (continentShade[worldFeature.feature.properties.CONTINENT] || "land-faint");
const classes = ["country", shade];
if (useRegionalChina) classes.push("country--regional");
const isSpecialFeature = isSpecialMapFeature(worldFeature.feature, special, boundary, useRegionalChina);
if (isSpecialFeature && visualBoundaryFeatures.length) continue;
if (isSpecialFeature) {
classes.push("is-special-country");
}
landEntries.push({ worldFeature, path, classes });
}
const labelEntries = [];
for (const entry of landEntries) {
if (!special) {
const label = countryLabelEntry(entry.worldFeature, projection);
if (label) labelEntries.push({ entry, label });
}
}
const renderKeys = [
...landEntries.map((entry) => `land:${entry.worldFeature.id || featureIsoA3(entry.worldFeature.feature)}`),
...labelEntries.map(({ entry }) => `label:${entry.worldFeature.id || featureIsoA3(entry.worldFeature.feature)}`),
].join("|");
if (state.worldRenderKeys === renderKeys && elements.worldLayer.childNodes.length === landEntries.length * 3 + labelEntries.length) {
const children = Array.from(elements.worldLayer.children);
landEntries.forEach((entry, index) => {
const shadow = children[index];
const country = children[landEntries.length + index * 2];
const texture = children[landEntries.length + index * 2 + 1];
shadow.setAttribute("d", entry.path);
country.setAttribute("d", entry.path);
texture.setAttribute("d", entry.path);
});
labelEntries.forEach(({ label }, index) => {
const text = children[landEntries.length * 3 + index];
text.setAttribute("x", label.x.toFixed(2));
text.setAttribute("y", label.y.toFixed(2));
});
return;
}
for (const entry of landEntries) {
fragment.append(
createSvg("path", {
d: entry.path,
class: "country-shadow",
}),
);
}
for (const entry of landEntries) {
fragment.append(
createSvg("path", {
d: entry.path,
class: entry.classes.join(" "),
}),
);
fragment.append(
createSvg("path", {
d: entry.path,
class: "country-texture",
}),
);
}
if (!special) {
for (const { label } of labelEntries) {
fragment.append(
createSvg("text", {
x: label.x.toFixed(2),
y: label.y.toFixed(2),
class: `country-label country-label--rank-${label.rank}`,
"text-anchor": "middle",
"dominant-baseline": "middle",
}, label.name),
);
}
}
state.worldRenderKeys = renderKeys;
elements.worldLayer.replaceChildren(fragment);
}
function renderSpecialBoundary(projection, options = {}) {
const special = activeSpecial();
if (!special) return;
const fragment = document.createDocumentFragment();
if (!special.cityMap || special.useLocalContext) {
const context = mapData.specialContexts.get(special.cityId);
for (const contextFeature of context?.features || []) {
const path = featureEntryToPath(contextFeature, projection, options);
if (!path) continue;
const kind = contextFeature.feature.properties?.kind || "line";
fragment.append(
createSvg("path", {
d: path,
class: `special-context special-context--${kind}`,
}),
);
}
}
if (special.showBoundary === false) {
elements.highlightLayer.replaceChildren(fragment);
return;
}
const boundary = mapData.specialBoundaries.get(special.cityId);
const matchedFeatures = specialBoundaryDisplayFeatures(special, boundary);
const paths = matchedFeatures
.map((worldFeature) => featureEntryToPath(worldFeature, projection, options))
.filter(Boolean);
if (paths.length) {
fragment.append(
createSvg("path", {
d: paths.join(" "),
class: "special-boundary",
}),
);
}
elements.highlightLayer.replaceChildren(fragment);
}
function renderCityTileLayer(projection) {
const special = activeSpecial();
if (!elements.cityTileLayer) return;
if (
!special?.cityMap
|| state.zoom < Math.max(18, special.focusZoom * 0.38)
) {
if (
!elements.cityTileLayer.hidden
|| elements.cityTileLayer.childNodes.length
|| elements.cityTileLayer.classList.contains("is-loading")
|| elements.cityTileLayer.classList.contains("is-ready")
|| elements.cityTileLayer.dataset.tileRenderKey
) {
elements.cityTileLayer.replaceChildren();
}
elements.cityTileLayer.hidden = true;
elements.cityTileLayer.classList.remove("is-loading", "is-ready");
delete elements.cityTileLayer.dataset.tileRenderKey;
return;
}
elements.cityTileLayer.hidden = false;
const stageRect = elements.mapStage.getBoundingClientRect();
const layout = cityTileLayoutForSpecial(special, stageRect, state.center);
if (!layout) return;
const {
tileProvider,
tileSize,
cityMapScale,
worldSize,
topLeft,
tileRenderKey,
tiles,
} = layout;
if (
elements.cityTileLayer.dataset.tileRenderKey === tileRenderKey
&& elements.cityTileLayer.querySelector(".city-tile-layer__tiles")
) {
elements.cityTileLayer.style.setProperty("--tile-center-x", `${projection.cx}px`);
elements.cityTileLayer.style.setProperty("--tile-center-y", `${projection.cy}px`);
if (elements.cityTileLayer.classList.contains("is-ready")) {
markCitySpecialAssetReady(special, "tiles");
}
return;
}
elements.cityTileLayer.dataset.tileRenderKey = tileRenderKey;
elements.cityTileLayer.style.setProperty("--city-map-scale", String(cityMapScale));
elements.cityTileLayer.classList.remove("is-ready");
elements.cityTileLayer.classList.add("is-loading");
const fragment = document.createDocumentFragment();
const tileFragment = document.createDocumentFragment();
const tilesLayer = document.createElement("div");
tilesLayer.className = "city-tile-layer__tiles";
const tileImages = [];
for (const tile of tiles) {
warmCityTileImage(tile.url, "high");
const img = document.createElement("img");
img.className = "city-tile-layer__tile";
img.alt = "";
img.crossOrigin = "anonymous";
img.decoding = "async";
img.loading = "eager";
if ("fetchPriority" in img) img.fetchPriority = "high";
img.draggable = false;
img.src = tile.url;
img.style.width = `${tileSize}px`;
img.style.height = `${tileSize}px`;
// Keep each source tile on the device-pixel grid. Fractional tile origins
// are visibly resampled on scaled/external displays.
img.style.left = `${Math.round(tile.left)}px`;
img.style.top = `${Math.round(tile.top)}px`;
tileImages.push(img);
tileFragment.append(img);
}
tilesLayer.append(tileFragment);
const oceanOverlay = document.createElement("div");
oceanOverlay.className = "city-tile-layer__ocean-overlay";
oceanOverlay.classList.add("is-ready");
fragment.append(oceanOverlay, tilesLayer);
const attribution = document.createElement("span");
attribution.className = "city-tile-layer__attribution";
attribution.textContent = tileProvider.attribution;
fragment.append(attribution);
elements.cityTileLayer.style.setProperty("--tile-center-x", `${projection.cx}px`);
elements.cityTileLayer.style.setProperty("--tile-center-y", `${projection.cy}px`);
elements.cityTileLayer.style.setProperty("--tile-world-size", `${worldSize}px`);
elements.cityTileLayer.replaceChildren(fragment);
let pendingTiles = tileImages.length;
const markTileReady = () => {
if (elements.cityTileLayer.dataset.tileRenderKey !== tileRenderKey) return;
pendingTiles -= 1;
if (pendingTiles > 0) return;
elements.cityTileLayer.classList.remove("is-loading");
elements.cityTileLayer.classList.add("is-ready");
markCitySpecialAssetReady(special, "tiles");
};
if (!pendingTiles) {
markTileReady();
return;
}
tileImages.forEach((img) => removeCityTileAdminBoundaryPixels(img, markTileReady));
}
function cityTileLayoutForSpecial(special, stageRect, center = state.center) {
if (!special?.cityMap) return null;
const width = Math.max(1, Math.round(stageRect?.width || window.innerWidth || 1));
const height = Math.max(1, Math.round(stageRect?.height || window.innerHeight || 1));
const tileProvider = resolveCityTileProvider(special, currentTileRuntimeConfig());
const tileZoom = special.tileZoom || 12;
const tileSize = tileProvider.tileSize || 256;
const cityMapScale = Number(special.cityMapScale) > 0 ? Number(special.cityMapScale) : 1;
const centerPixel = mercatorPixel(center, tileZoom, tileSize);
const topLeft = {
x: centerPixel.x - width / 2,
y: centerPixel.y - height / 2,
};
const tileCount = 2 ** tileZoom;
const worldSize = tileSize * tileCount;
const minX = Math.floor(topLeft.x / tileSize) - 1;
const maxX = Math.floor((topLeft.x + width) / tileSize) + 1;
const minY = Math.max(0, Math.floor(topLeft.y / tileSize) - 1);
const maxY = Math.min(tileCount - 1, Math.floor((topLeft.y + height) / tileSize) + 1);
const tiles = [];
for (let tileY = minY; tileY <= maxY; tileY += 1) {
for (let tileX = minX; tileX <= maxX; tileX += 1) {
const wrappedX = ((tileX % tileCount) + tileCount) % tileCount;
const url = buildCityTileUrl(tileProvider, { z: tileZoom, x: wrappedX, y: tileY });
if (!url) continue;
tiles.push({
url,
left: tileX * tileSize - topLeft.x,
top: tileY * tileSize - topLeft.y,
});
}
}
return {
tileProvider,
tileZoom,
tileSize,
cityMapScale,
worldSize,
topLeft,
tiles,
tileRenderKey: [
special.id,
tileProvider.name,
tileZoom,
tileSize,
cityMapScale,
width,
height,
minX,
maxX,
minY,
maxY,
topLeft.x.toFixed(2),
topLeft.y.toFixed(2),
].join("|"),
};
}
function warmCitySpecialTileImages(special, priority = "low") {
if (!special?.cityMap) return;
const stageRect = elements.mapStage?.getBoundingClientRect?.();
const layout = cityTileLayoutForSpecial(special, stageRect, specialViewCenter(special));
if (!layout) return;
layout.tiles.forEach((tile) => warmCityTileImage(tile.url, priority));
}
function warmCityTileImage(url, priority = "low") {
if (!url) return null;
const existing = cityTileImagePreloadCache.get(url);
const now = performance.now();
if (existing) {
existing.lastUsedAt = now;
if (priority === "high" && existing.image && "fetchPriority" in existing.image) {
existing.image.fetchPriority = "high";
}
return existing.promise;
}
const image = new Image();
image.crossOrigin = "anonymous";
image.decoding = "async";
image.loading = "eager";
if ("fetchPriority" in image) image.fetchPriority = priority;
const entry = {
image,
status: "loading",
lastUsedAt: now,
promise: null,
};
let settled = false;
entry.promise = new Promise((resolve) => {
const finish = (status) => {
if (settled) return;
settled = true;
entry.status = status;
entry.lastUsedAt = performance.now();
resolve(entry);
};
image.addEventListener("load", () => {
if (typeof image.decode === "function") {
image.decode().then(() => finish("ready")).catch(() => finish("ready"));
} else {
finish("ready");
}
}, { once: true });
image.addEventListener("error", () => finish("failed"), { once: true });
image.src = url;
if (image.complete) finish(image.naturalWidth > 0 ? "ready" : "failed");
});
cityTileImagePreloadCache.set(url, entry);
pruneCityTileImagePreloadCache();
return entry.promise;
}
function pruneCityTileImagePreloadCache() {
if (cityTileImagePreloadCache.size <= CITY_TILE_PRELOAD_LIMIT) return;
const removable = Array.from(cityTileImagePreloadCache.entries())
.filter(([, entry]) => entry.status !== "loading")
.sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt);
while (cityTileImagePreloadCache.size > CITY_TILE_PRELOAD_LIMIT && removable.length) {
cityTileImagePreloadCache.delete(removable.shift()[0]);
}
}
function cacheCityTilePixels(sourceUrl, filteredSource) {
if (cityTilePixelCache.has(sourceUrl)) cityTilePixelCache.delete(sourceUrl);
while (cityTilePixelCache.size >= CITY_TILE_PIXEL_CACHE_LIMIT) {
const oldestKey = cityTilePixelCache.keys().next().value;
if (oldestKey == null) break;
cityTilePixelCache.delete(oldestKey);
}
cityTilePixelCache.set(sourceUrl, filteredSource);
}
function settleCityTileImageWhenDecoded(img, onSettled) {
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
onSettled?.();
};
const finish = () => requestAnimationFrame(settle);
if (typeof img.decode === "function" && img.complete && img.naturalWidth) {
img.decode().then(finish).catch(finish);
return;
}
finish();
}
function removeCityTileAdminBoundaryPixels(img, onSettled) {
if (!img) {
onSettled?.();
return;
}
const sourceUrl = img.src;
const settle = () => settleCityTileImageWhenDecoded(img, onSettled);
if (img.dataset.adminBoundaryFiltered === "done" || img.dataset.adminBoundaryFiltered === "failed") {
settle();
return;
}
if (cityTilePixelCache.has(sourceUrl)) {
const cachedSource = cityTilePixelCache.get(sourceUrl);
img.dataset.adminBoundaryFiltered = "done";
if (!cachedSource) {
if (img.complete) {
settle();
} else {
const finishCachedOriginal = () => settle();
img.addEventListener("load", finishCachedOriginal, { once: true });
img.addEventListener("error", finishCachedOriginal, { once: true });
}
return;
}
const finishCachedImage = () => settle();
img.addEventListener("load", finishCachedImage, { once: true });
img.addEventListener("error", finishCachedImage, { once: true });
img.src = cachedSource;
return;
}
const applyFilter = () => {
if (img.dataset.adminBoundaryFiltered === "done" || img.dataset.adminBoundaryFiltered === "failed") {
settle();
return;
}
if (!img.naturalWidth || !img.naturalHeight) {
img.dataset.adminBoundaryFiltered = "failed";
settle();
return;
}
try {
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("city tile pixel context unavailable");
context.drawImage(img, 0, 0);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const { data } = imageData;
let changed = false;
for (let index = 0; index < data.length; index += 4) {
if (isFaintCityTileOceanResiduePixel(data[index], data[index + 1], data[index + 2], data[index + 3])) {
data[index + 3] = 0;
changed = true;
continue;
}
if (isLikelyAdminBoundaryPixel(data[index], data[index + 1], data[index + 2], data[index + 3])) {
data[index + 3] = 0;
changed = true;
continue;
}
const mutedGreen = isLikelyBasemapGreenAreaPixel(data[index], data[index + 1], data[index + 2], data[index + 3])
? mutedCityTileGreenPixel(data[index], data[index + 1], data[index + 2])
: null;
if (mutedGreen) {
data[index] = mutedGreen[0];
data[index + 1] = mutedGreen[1];
data[index + 2] = mutedGreen[2];
changed = true;
}
}
changed = paintCityTileCoastShadowPixels(data, canvas.width, canvas.height) || changed;
img.dataset.adminBoundaryFiltered = "done";
if (!changed) {
cacheCityTilePixels(sourceUrl, "");
settle();
return;
}
context.putImageData(imageData, 0, 0);
const filteredSource = canvas.toDataURL("image/png");
cacheCityTilePixels(sourceUrl, filteredSource);
const finishFilteredImage = () => settle();
img.addEventListener("load", finishFilteredImage, { once: true });
img.addEventListener("error", finishFilteredImage, { once: true });
img.src = filteredSource;
} catch {
img.dataset.adminBoundaryFiltered = "failed";
settle();
}
};
if (img.complete && img.naturalWidth) {
applyFilter();
return;
}
img.addEventListener("load", applyFilter, { once: true });
img.addEventListener("error", () => {
img.dataset.adminBoundaryFiltered = "failed";
settle();
}, { once: true });
}
function isFaintCityTileOceanResiduePixel(r, g, b, a) {
return a > 0
&& a <= 96
&& b >= r + 28
&& b >= g + 18;
}
function paintCityTileCoastShadowPixels(data, width, height) {
const alpha = new Uint8ClampedArray(width * height);
for (let index = 0, pixel = 0; index < data.length; index += 4, pixel += 1) {
alpha[pixel] = data[index + 3];
}
const shadowAlpha = new Uint8ClampedArray(width * height);
let hasShadow = false;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const pixel = y * width + x;
if (alpha[pixel] < 72 || !cityTileLandPixelTouchesOcean(alpha, width, height, x, y)) continue;
for (let dy = -CITY_TILE_COAST_SHADOW_RADIUS; dy <= CITY_TILE_COAST_SHADOW_RADIUS; dy += 1) {
const targetY = y + dy;
if (targetY < 0 || targetY >= height) continue;
for (let dx = -CITY_TILE_COAST_SHADOW_RADIUS; dx <= CITY_TILE_COAST_SHADOW_RADIUS; dx += 1) {
const targetX = x + dx;
if (targetX < 0 || targetX >= width) continue;
const distance = Math.hypot(dx, dy);
if (distance <= 0 || distance > CITY_TILE_COAST_SHADOW_RADIUS) continue;
const targetPixel = targetY * width + targetX;
if (alpha[targetPixel] >= 24) continue;
const strength = cityTileCoastShadowStrength(distance);
if (strength > shadowAlpha[targetPixel]) shadowAlpha[targetPixel] = strength;
}
}
}
}
for (let pixel = 0; pixel < shadowAlpha.length; pixel += 1) {
const shadow = shadowAlpha[pixel];
if (!shadow) continue;
const index = pixel * 4;
data[index] = CITY_TILE_COAST_SHADOW_COLOR[0];
data[index + 1] = CITY_TILE_COAST_SHADOW_COLOR[1];
data[index + 2] = CITY_TILE_COAST_SHADOW_COLOR[2];
data[index + 3] = Math.max(data[index + 3], shadow);
hasShadow = true;
}
return hasShadow;
}
function cityTileCoastShadowStrength(distance) {
const fade = 1 - distance / (CITY_TILE_COAST_SHADOW_RADIUS + 1);
return Math.round((Math.max(0, fade) ** CITY_TILE_COAST_SHADOW_FALLOFF) * CITY_TILE_COAST_SHADOW_MAX_ALPHA);
}
function cityTileLandPixelTouchesOcean(alpha, width, height, x, y) {
for (let dy = -1; dy <= 1; dy += 1) {
const targetY = y + dy;
if (targetY < 0 || targetY >= height) continue;
for (let dx = -1; dx <= 1; dx += 1) {
if (dx === 0 && dy === 0) continue;
const targetX = x + dx;
if (targetX < 0 || targetX >= width) continue;
if (alpha[targetY * width + targetX] < 24) return true;
}
}
return false;
}
function isLikelyAdminBoundaryPixel(r, g, b, a) {
if (a < 16) return false;
if (isPaleRedAdministrativeBoundaryPixel(r, g, b)) return true;
if (isLikelyBasemapRedLinePixel(r, g, b)) return true;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const lightness = (r + g + b) / 3;
const saturation = max ? (max - min) / max : 0;
return lightness > 125
&& lightness < 245
&& saturation > 0.07
&& saturation < 0.32
&& r > g + 5
&& r > b + 12
&& g >= b - 8
&& g - b <= 22;
}
function isLikelyBasemapRedLinePixel(r, g, b) {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const lightness = (r + g + b) / 3;
const saturation = max ? (max - min) / max : 0;
const redBias = r - g;
const blueBias = r - b;
const greenBlueGap = g - b;
return lightness > 72
&& lightness < 248
&& saturation > 0.025
&& saturation < 0.64
&& redBias >= 2
&& blueBias >= 6
&& greenBlueGap >= -22
&& greenBlueGap <= 48;
}
function isPaleRedAdministrativeBoundaryPixel(r, g, b) {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const lightness = (r + g + b) / 3;
const redBias = r - g;
const greenBias = g - b;
const blueBias = r - b;
return lightness > 150
&& lightness < 252
&& max - min <= 46
&& redBias >= 5
&& redBias <= 32
&& greenBias >= 2
&& greenBias <= 18
&& blueBias >= 10
&& blueBias <= 44;
}
function isLikelyBasemapGreenAreaPixel(r, g, b, a) {
if (a < 16) return false;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const lightness = (r + g + b) / 3;
const saturation = max ? (max - min) / max : 0;
const greenBias = g - r;
const blueGreenGap = g - b;
return lightness > 72
&& lightness < 236
&& saturation > 0.07
&& saturation < 0.58
&& greenBias >= 10
&& blueGreenGap <= 22
&& b >= r - 18;
}
function mutedCityTileGreenPixel(r, g, b) {
const lightness = (r + g + b) / 3;
const warmStone = clamp(156 + (lightness - 150) * 0.32, 108, 214);
return [
Math.round(warmStone + 14),
Math.round(warmStone + 8),
Math.round(warmStone - 8),
];
}
function mercatorPixel([lon, lat], zoom, tileSize = 256) {
const scale = tileSize * 2 ** zoom;
const clampedLat = clamp(lat, -85.05112878, 85.05112878);
const sinLat = Math.sin(toRadians(clampedLat));
return {
x: ((lon + 180) / 360) * scale,
y: (0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI)) * scale,
};
}
function specialBoundaryDisplayFeatures(special, boundary) {
const visualFeatures = boundary?.visualFeatures || [];
if (visualFeatures.length) return visualFeatures;
const baseFeatures = currentSpecialBaseFeatures(special, boundary);
if (baseFeatures.length) return baseFeatures;
if (boundary?.outlineFeatures?.length) return boundary.outlineFeatures;
return boundary?.features || [];
}
function currentSpecialBaseFeatures(special, boundary) {
if (!special) return [];
const useRegionalChina = Boolean(special.city?.isoA3 === "CHN" && mapData.chinaFeatures.length);
const features = useRegionalChina ? mapData.chinaFeatures : mapData.worldFeatures;
return features.filter((worldFeature) => isSpecialMapFeature(worldFeature.feature, special, boundary, useRegionalChina));
}
function renderSpecialLabels(projection) {
if (state.activeSpecialId) {
for (const entry of specialLabelCache.values()) entry.element.hidden = true;
return;
}
const visibleSpecialIds = new Set();
for (const special of mapData.specials) {
const [lon, lat] = cityCoordinates(special.city);
const [x, y, visible] = projectPoint([lon, lat], projection);
if (!visible) continue;
visibleSpecialIds.add(special.id);
const point = svgToStageFreePoint(x, y);
const entry = specialLabelCache.get(special.id) || createSpecialLabel(special);
const [offsetX, offsetY] = special.labelOffset || [18, -56];
entry.element.hidden = false;
entry.element.style.setProperty("--special-x", `${(point.x + offsetX).toFixed(2)}px`);
entry.element.style.setProperty("--special-y", `${(point.y + offsetY).toFixed(2)}px`);
if (entry.element.parentNode !== elements.specialLabelLayer) {
elements.specialLabelLayer.append(entry.element);
}
}
for (const [specialId, entry] of specialLabelCache.entries()) {
entry.element.hidden = !visibleSpecialIds.has(specialId);
}
}
function createSpecialLabel(special) {
const button = document.createElement("button");
button.className = "special-capsule";
button.type = "button";
const entryName = special.entryName || special.specialName;
button.setAttribute("aria-label", `打开 ${entryName}`);
const mark = createSpecialEntryMark(special.id);
const title = document.createElement("span");
title.className = "special-capsule__title";
title.textContent = entryName;
button.append(mark, title);
button.addEventListener("pointerdown", (event) => {
event.stopPropagation();
});
button.addEventListener("pointerenter", () => warmCitySpecialMapAssets(special, "high"));
button.addEventListener("focus", () => warmCitySpecialMapAssets(special, "high"));
button.addEventListener("click", (event) => {
event.stopPropagation();
warmCitySpecialMapAssets(special, "high");
playUiSound("bookmarkOpen");
enterSpecialView(special.id);
});
const entry = { element: button, title };
specialLabelCache.set(special.id, entry);
return entry;
}
function renderSpecialBooks(projection) {
const special = activeSpecial();
if (!special) {
clearSpecialBookLayers();
return;
}
const stageRect = elements.mapStage.getBoundingClientRect();
prepareSpecialOverlay(stageRect);
renderActiveSpecialBooks(special, projection, stageRect);
}
function clearSpecialBookLayers() {
if (elements.specialOverlaySvg.childNodes.length) elements.specialOverlaySvg.replaceChildren();
if (elements.specialBookLayer.childNodes.length) elements.specialBookLayer.replaceChildren();
elements.specialOverlaySvg.classList.remove("is-loading");
elements.specialOverlaySvg.classList.remove("is-ready");
elements.specialBookLayer.classList.remove("is-loading");
elements.specialBookLayer.classList.remove("is-ready");
state.specialStackRenderData.clear();
state.specialBookRenderData.clear();
}
function renderActiveSpecialBooks(special, projection, stageRect) {
const [lon, lat] = cityCoordinates(special.city);
const [x, y, visible] = projectPoint([lon, lat], projection);
if (!visible) {
elements.specialOverlaySvg.replaceChildren();
elements.specialBookLayer.replaceChildren();
elements.specialOverlaySvg.classList.remove("is-loading");
elements.specialOverlaySvg.classList.remove("is-ready");
elements.specialBookLayer.classList.remove("is-loading");
elements.specialBookLayer.classList.remove("is-ready");
state.specialStackRenderData.clear();
state.specialBookRenderData.clear();
return;
}
const origin = svgToStageFreePoint(x, y);
const books = special.books.length ? special.books : [curatingPlaceholderBook(special)];
const bookEntries = spreadSpecialBookEntries(special, books);
if (special.cityMap) {
const readyToken = beginSpecialBookLayerLoad(special);
const visibleEntries = orderCityMapBookEntries(bookEntries, special).slice(0, SPECIAL_BOOK_OFFSETS.length);
const layoutSpecial = {
...special,
cityMapSpreadRows: specialBookSpreadRows(visibleEntries),
};
const hasCompleteManualLayout = hasCompleteCityMapSpecialBookLayout(special, visibleEntries);
const defaultBookPoints = applyCityMapSpecialBookLayout(
special,
visibleEntries,
hasCompleteManualLayout
? visibleEntries.map(() => ({ x: 0, y: 0 }))
: resolveSpecialBookPoints(
visibleEntries.map((entry, index) => specialBookPoint(origin, index, stageRect, layoutSpecial, "book")),
origin,
stageRect,
layoutSpecial,
"book",
),
stageRect,
);
const bookPoints = visibleEntries.map((entry, index) => (
state.specialBookDraftPoints.get(specialBookDraftKey(special.id, entry.book.id)) || defaultBookPoints[index]
));
const lineFragment = document.createDocumentFragment();
const bookFragment = document.createDocumentFragment();
const nextBookRenderData = new Map();
visibleEntries.forEach((entry, index) => {
const point = bookPoints[index];
const hoverKey = specialBookKey(special.id, entry.sourceIndex);
const anchor = specialBookAnchor(special, entry.book, origin, entry.sourceIndex, stageRect, projection);
lineFragment.append(
createSvg("path", {
d: specialConnectorPath(anchor, point, index, cityMapConnectorPathOptions(point, stageRect)),
class: "special-book-line",
"data-book-key": hoverKey,
style: `--book-delay: ${Math.min(index * 34, 260)}ms`,
}),
createSpecialBookAnchorElement(anchor, hoverKey, index),
);
const bookElement = createSpecialBookElement(special, entry.book, point, entry.sourceIndex, hoverKey, "special", entry.category);
bookElement.style.setProperty("--book-delay", `${Math.min(index * 72, 620)}ms`);
bookFragment.append(bookElement);
nextBookRenderData.set(hoverKey, {
special,
book: entry.book,
point,
index,
sourceIndex: entry.sourceIndex,
hoverKey,
anchor,
stageRect,
});
});
state.specialStackRenderData.clear();
state.specialBookRenderData = nextBookRenderData;
elements.specialOverlaySvg.replaceChildren(lineFragment);
elements.specialBookLayer.replaceChildren(bookFragment);
revealSpecialBookLayerWhenReady(special, readyToken);
return;
}
const readyToken = beginSpecialBookLayerLoad(special);
const bookGroups = specialBookGroups(special, books);
const lineFragment = document.createDocumentFragment();
const bookFragment = document.createDocumentFragment();
const nextStackRenderData = new Map();
const visibleGroups = bookGroups.slice(0, SPECIAL_CATEGORY_STACK_OFFSETS.length);
const bookPoints = resolveSpecialBookPoints(
visibleGroups.map((group, index) => specialBookPoint(origin, index, stageRect, special, "stack")),
origin,
stageRect,
special,
"stack",
);
visibleGroups.forEach((group, index) => {
const point = bookPoints[index];
const hoverKey = specialBookStackKey(special.id, group.category);
appendSpecialBookConnectors(lineFragment, special, group, point, index, hoverKey, origin, projection, stageRect);
bookFragment.append(createSpecialBookStackElement(special, group, point, index, hoverKey));
nextStackRenderData.set(hoverKey, {
special,
group,
point,
index,
hoverKey,
origin,
projection,
stageRect,
});
});
state.specialStackRenderData = nextStackRenderData;
state.specialBookRenderData.clear();
elements.specialOverlaySvg.replaceChildren(lineFragment);
elements.specialBookLayer.replaceChildren(bookFragment);
revealSpecialBookLayerWhenReady(special, readyToken);
}
function specialStackElementSelector(stackKey) {
return `[data-stack-key="${cssAttributeValue(stackKey)}"]`;
}
function specialBookOverlaySelector(hoverKey, className) {
const classSelector = className ? `.${className}` : "";
return `${classSelector}[data-book-key="${cssAttributeValue(hoverKey)}"]`;
}
function updateSpecialBookStack(stackKey) {
const data = state.specialStackRenderData.get(stackKey);
const current = elements.specialBookLayer.querySelector(specialStackElementSelector(stackKey));
if (!data || !current) {
render();
return;
}
const nextStack = createSpecialBookStackElement(data.special, data.group, data.point, data.index, data.hoverKey);
syncSpecialBookStackElement(current, nextStack);
updateSpecialBookConnector(data);
syncSpecialStackActiveClasses();
}
function syncSpecialBookStackElement(current, nextStack) {
current.className = nextStack.className;
current.dataset.bookKey = nextStack.dataset.bookKey;
current.dataset.stackKey = nextStack.dataset.stackKey;
current.style.cssText = nextStack.style.cssText;
const nextChildren = Array.from(nextStack.childNodes);
current.replaceChildren(...nextChildren);
scheduleTicketCutPaths(current);
}
function updateSpecialBookConnector(data) {
elements.specialOverlaySvg
.querySelectorAll(specialBookOverlaySelector(data.hoverKey))
.forEach((element) => element.remove());
const fragment = document.createDocumentFragment();
appendSpecialBookConnectors(fragment, data.special, data.group, data.point, data.index, data.hoverKey, data.origin, data.projection, data.stageRect);
elements.specialOverlaySvg.append(fragment);
}
function syncSpecialStackActiveClasses() {
elements.specialBookLayer.querySelectorAll(".special-book-stack").forEach((stack) => {
stack.classList.toggle("is-stack-active", stack.dataset.stackKey === state.activeSpecialStackKey);
});
}
function beginSpecialBookLayerLoad(special) {
const bookLayer = elements.specialBookLayer;
const overlay = elements.specialOverlaySvg;
const token = `${special.id}:${(Number(bookLayer.dataset.readyToken) || 0) + 1}`;
bookLayer.dataset.readyToken = token;
overlay.dataset.readyToken = token;
bookLayer.classList.remove("is-ready");
overlay.classList.remove("is-ready");
bookLayer.classList.add("is-loading");
overlay.classList.add("is-loading");
return token;
}
function revealSpecialBookLayerWhenReady(special, token = beginSpecialBookLayerLoad(special)) {
const bookLayer = elements.specialBookLayer;
const overlay = elements.specialOverlaySvg;
const reveal = () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (bookLayer.dataset.readyToken !== token || overlay.dataset.readyToken !== token) return;
updateTicketCutPaths(bookLayer);
alignCityMapSpecialBookEdges(special, false);
bookLayer.classList.add("is-ready");
overlay.classList.add("is-ready");
if (!special.cityMap) {
bookLayer.classList.remove("is-loading");
overlay.classList.remove("is-loading");
}
markCitySpecialAssetReady(special, "books");
});
});
};
reveal();
}
function beginCitySpecialAssetLoad(special) {
const root = document.documentElement;
if (state.citySpecialReadyTimer) {
window.clearTimeout(state.citySpecialReadyTimer);
state.citySpecialReadyTimer = null;
}
if (state.citySpecialLandRevealTimer) {
window.clearTimeout(state.citySpecialLandRevealTimer);
state.citySpecialLandRevealTimer = null;
}
root.dataset.citySpecialLoadId = special.id;
root.dataset.citySpecialTilesReady = "0";
root.dataset.citySpecialBooksReady = "0";
root.dataset.citySpecialOceanReady = "0";
root.dataset.citySpecialLandRevealed = "0";
root.dataset.citySpecialOceanTransitionStarted = "0";
root.dataset.citySpecialOceanStartedAt = String(performance.now());
root.classList.remove("is-city-special-ready", "is-city-special-land-ready", "is-city-special-content-ready", "is-city-special-ocean-ready", "is-city-ocean-entering");
root.classList.add("is-special-view", "is-special-city-map", "is-city-special-loading", "is-city-ocean-preparing");
prepareCityTileLayerForLoad();
prepareCitySpecialContentLayersForLoad();
preloadCitySpecialOceanTexture(special);
}
function prepareCityTileLayerForLoad() {
elements.cityTileLayer.replaceChildren();
elements.cityTileLayer.classList.remove("is-ready");
elements.cityTileLayer.classList.add("is-loading");
delete elements.cityTileLayer.dataset.tileRenderKey;
}
function prepareCitySpecialContentLayersForLoad() {
elements.specialOverlaySvg.replaceChildren();
elements.specialBookLayer.replaceChildren();
elements.specialOverlaySvg.classList.remove("is-ready");
elements.specialBookLayer.classList.remove("is-ready");
elements.specialOverlaySvg.classList.add("is-loading");
elements.specialBookLayer.classList.add("is-loading");
delete elements.specialOverlaySvg.dataset.readyToken;
delete elements.specialBookLayer.dataset.readyToken;
state.activeSpecialStackKey = null;
state.specialStackRenderData.clear();
state.specialBookRenderData.clear();
state.specialBookSuppressedClickKey = null;
}
function startCityOceanTransition(special) {
const root = document.documentElement;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (root.dataset.citySpecialLoadId !== special.id) return;
root.dataset.citySpecialOceanTransitionStarted = "1";
root.dataset.citySpecialOceanStartedAt = String(performance.now());
root.classList.remove("is-city-ocean-preparing");
root.classList.add("is-city-ocean-entering");
markCitySpecialAssetReady(special, "transition");
});
});
}
function preloadCitySpecialOceanTexture(special) {
warmCitySpecialOceanTexture(state.theme, () => {
markCitySpecialAssetReady(special, "ocean");
});
}
function warmCitySpecialMapAssets(special, priority = "low") {
if (!special?.cityMap) return;
warmCitySpecialOceanTexture(state.theme);
warmCitySpecialTileImages(special, priority);
}
function warmCitySpecialOceanTexture(theme = state.theme, onReady) {
const source = citySpecialOceanTextureHref(theme);
if (citySpecialOceanTextureCache.has(source)) {
onReady?.();
return;
}
const image = new Image();
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
citySpecialOceanTextureCache.add(source);
onReady?.();
};
image.decoding = "async";
image.addEventListener("load", settle, { once: true });
image.addEventListener("error", settle, { once: true });
image.src = source;
if (image.complete) settle();
}
function markCitySpecialAssetReady(special, part) {
const root = document.documentElement;
if (root.dataset.citySpecialLoadId !== special.id) return;
if (part === "tiles") root.dataset.citySpecialTilesReady = "1";
if (part === "books") root.dataset.citySpecialBooksReady = "1";
if (part === "ocean") {
root.dataset.citySpecialOceanReady = "1";
root.classList.add("is-city-special-ocean-ready");
}
revealCitySpecialLandWhenReady(special);
revealCitySpecialContentWhenReady(special);
if (
root.dataset.citySpecialOceanReady !== "1"
|| root.dataset.citySpecialOceanTransitionStarted !== "1"
|| (special.cityMap && root.dataset.citySpecialTilesReady !== "1")
) return;
const startedAt = Number(root.dataset.citySpecialOceanStartedAt) || performance.now();
const elapsed = performance.now() - startedAt;
const remaining = Math.max(0, CITY_OCEAN_TRANSITION_MIN_MS + CITY_OCEAN_TRANSITION_SETTLE_MS - elapsed);
if (remaining > 0) {
if (!state.citySpecialReadyTimer) {
state.citySpecialReadyTimer = window.setTimeout(() => {
state.citySpecialReadyTimer = null;
finishCitySpecialAssetLoad(special);
}, remaining);
}
return;
}
finishCitySpecialAssetLoad(special);
}
function citySpecialLandRevealDelay() {
return state.halftone.reducedMotion ? 0 : CITY_SPECIAL_BOOK_REVEAL_AFTER_LAND_MS;
}
function revealCitySpecialLandWhenReady(special) {
if (!special?.cityMap) return;
const root = document.documentElement;
if (root.dataset.citySpecialLoadId !== special.id) return;
if (root.classList.contains("is-city-special-land-ready")) return;
if (
root.dataset.citySpecialTilesReady !== "1"
|| root.dataset.citySpecialOceanTransitionStarted !== "1"
) return;
const startedAt = Number(root.dataset.citySpecialOceanStartedAt) || performance.now();
const elapsed = performance.now() - startedAt;
const remaining = Math.max(0, CITY_SPECIAL_LAND_REVEAL_MIN_MS - elapsed);
if (remaining > 0) {
if (!state.citySpecialLandRevealTimer) {
state.citySpecialLandRevealTimer = window.setTimeout(() => {
state.citySpecialLandRevealTimer = null;
revealCitySpecialLandWhenReady(special);
}, remaining);
}
return;
}
root.classList.add("is-city-special-land-ready");
state.citySpecialLandRevealTimer = window.setTimeout(() => {
state.citySpecialLandRevealTimer = null;
if (root.dataset.citySpecialLoadId !== special.id) return;
root.dataset.citySpecialLandRevealed = "1";
revealCitySpecialContentWhenReady(special);
}, citySpecialLandRevealDelay());
}
function revealCitySpecialContentWhenReady(special) {
if (!special?.cityMap) return;
const root = document.documentElement;
if (root.dataset.citySpecialLoadId !== special.id) return;
if (root.classList.contains("is-city-special-content-ready")) return;
if (
root.dataset.citySpecialBooksReady !== "1"
|| root.dataset.citySpecialLandRevealed !== "1"
) return;
root.classList.add("is-city-special-content-ready");
elements.specialOverlaySvg.classList.remove("is-loading");
elements.specialBookLayer.classList.remove("is-loading");
}
function finishCitySpecialAssetLoad(special) {
const root = document.documentElement;
if (root.dataset.citySpecialLoadId !== special.id) return;
if (
root.dataset.citySpecialOceanReady !== "1"
|| root.dataset.citySpecialOceanTransitionStarted !== "1"
) return;
freezeCityOceanTransition();
root.classList.remove("is-city-special-loading", "is-city-ocean-preparing", "is-city-ocean-entering");
root.classList.add("is-city-special-ready");
stopCityMapLoadingSound();
revealCitySpecialLandWhenReady(special);
revealCitySpecialContentWhenReady(special);
}
function resetCitySpecialAssetLoad() {
const root = document.documentElement;
if (state.citySpecialReadyTimer) {
window.clearTimeout(state.citySpecialReadyTimer);
state.citySpecialReadyTimer = null;
}
if (state.citySpecialLandRevealTimer) {
window.clearTimeout(state.citySpecialLandRevealTimer);
state.citySpecialLandRevealTimer = null;
}
delete root.dataset.citySpecialLoadId;
delete root.dataset.citySpecialTilesReady;
delete root.dataset.citySpecialBooksReady;
delete root.dataset.citySpecialOceanReady;
delete root.dataset.citySpecialLandRevealed;
delete root.dataset.citySpecialOceanTransitionStarted;
delete root.dataset.citySpecialOceanStartedAt;
root.classList.remove("is-city-special-loading", "is-city-special-ready", "is-city-special-land-ready", "is-city-special-content-ready", "is-city-special-ocean-ready", "is-city-ocean-preparing", "is-city-ocean-entering");
stopCityMapLoadingSound();
}
function captureCityOceanTransitionFromGlobe() {
const projection = createProjection();
const center = svgToStageFreePoint(projection.cx, projection.cy);
const edge = svgToStageFreePoint(projection.cx + projection.radius, projection.cy);
const stageRadius = Math.hypot(edge.x - center.x, edge.y - center.y);
const scale = projection.radius > 0 ? stageRadius / projection.radius : 1;
const metrics = globeOceanTextureMetrics(projection);
const offsetX = center.x - projection.cx * scale;
const offsetY = center.y - projection.cy * scale;
return {
centerX: center.x,
centerY: center.y,
radius: stageRadius,
textureSize: metrics.tile * scale,
textureX: offsetX + metrics.x * scale,
textureY: offsetY + metrics.y * scale,
};
}
function applyCityOceanTransitionFromGlobe(transition) {
if (!elements.cityTileLayer || !transition) return;
elements.cityTileLayer.style.setProperty("--city-ocean-origin-x", `${roundCssPixel(transition.centerX)}px`);
elements.cityTileLayer.style.setProperty("--city-ocean-origin-y", `${roundCssPixel(transition.centerY)}px`);
elements.cityTileLayer.style.setProperty("--city-ocean-start-radius", `${roundCssPixel(transition.radius)}px`);
elements.cityTileLayer.style.setProperty("--city-ocean-start-size", `${roundCssPixel(transition.textureSize)}px`);
elements.cityTileLayer.style.setProperty("--city-ocean-start-x", `${roundCssPixel(transition.textureX)}px`);
elements.cityTileLayer.style.setProperty("--city-ocean-start-y", `${roundCssPixel(transition.textureY)}px`);
elements.cityTileLayer.style.setProperty("--city-ocean-final-size", `${CITY_OCEAN_TEXTURE_TILE}px`);
elements.cityTileLayer.style.setProperty("--city-ocean-final-x", "0px");
elements.cityTileLayer.style.setProperty("--city-ocean-final-y", "0px");
}
function freezeCityOceanTransition() {
if (!elements.cityTileLayer) return;
const root = document.documentElement;
const startedAt = Number(root.dataset.citySpecialOceanStartedAt) || performance.now();
const elapsed = Math.max(0, performance.now() - startedAt);
const startX = cssPixelNumber(elements.cityTileLayer, "--city-ocean-start-x");
const startY = cssPixelNumber(elements.cityTileLayer, "--city-ocean-start-y");
const finalSize = CITY_OCEAN_TEXTURE_TILE;
const drift = ((elapsed % CITY_OCEAN_TRANSITION_DRIFT_MS) / CITY_OCEAN_TRANSITION_DRIFT_MS) * finalSize;
elements.cityTileLayer.style.setProperty("--city-ocean-final-size", `${finalSize}px`);
elements.cityTileLayer.style.setProperty("--city-ocean-final-x", `${roundCssPixel(startX - drift)}px`);
elements.cityTileLayer.style.setProperty("--city-ocean-final-y", `${roundCssPixel(startY)}px`);
}
function globeOceanTextureMetrics(projection) {
const tile = GLOBAL_OCEAN_TEXTURE_TILE;
const wrapLon = ((((projection.centerLon % 360) + 360) % 360) / 360);
const clampLat = Math.max(-75, Math.min(75, projection.centerLat));
const texturePhase = toRadians(projection.centerLon * 1.7 + projection.centerLat * 0.9);
return {
tile,
x: -wrapLon * tile * 1.18,
y: (clampLat / 180) * tile * 0.42 + Math.sin(texturePhase * 1.16) * 1.4,
};
}
function cssPixelNumber(element, propertyName) {
return Number.parseFloat(element.style.getPropertyValue(propertyName)) || 0;
}
function roundCssPixel(value) {
return Math.round(value * 100) / 100;
}
function alignCityMapSpecialBookEdges(special, defer = true) {
const alignments = CITY_MAP_SPECIAL_BOOK_EDGE_ALIGNMENTS[special?.id];
if (!alignments?.length) return;
const apply = () => {
alignments.forEach((alignment) => {
const bookElements = alignment.ids
.map((bookId) => elements.specialBookLayer.querySelector(`.special-book${bookIdSelector(bookId)}`))
.filter(Boolean);
if (bookElements.length < 2) return;
const rects = bookElements.map((element) => element.getBoundingClientRect());
const target = alignment.edge === "bottom"
? Math.max(...rects.map((rect) => rect.bottom))
: Math.min(...rects.map((rect) => rect.top));
bookElements.forEach((element, index) => {
const rect = rects[index];
const current = alignment.edge === "bottom" ? rect.bottom : rect.top;
const currentY = Number.parseFloat(element.style.getPropertyValue("--book-y")) || 0;
const delta = target - current;
if (Math.abs(delta) < 0.5) return;
element.style.setProperty("--book-y", `${(currentY + delta).toFixed(2)}px`);
});
});
};
if (defer) requestAnimationFrame(apply);
else apply();
}
function bookIdSelector(bookId) {
return `[data-book-id="${cssAttributeValue(bookId)}"]`;
}
function specialBookGroups(special, books) {
const groups = [];
const byCategory = new Map();
books.forEach((book, sourceIndex) => {
const category = specialBookCategory(book, special);
if (!byCategory.has(category)) {
const group = {
category,
categoryEn: specialBookCategoryEn(category),
entries: [],
};
byCategory.set(category, group);
groups.push(group);
}
byCategory.get(category).entries.push({ book, sourceIndex });
});
return groups;
}
function spreadSpecialBookEntries(special, books) {
return books.map((book, sourceIndex) => ({
book,
sourceIndex,
category: specialBookCategory(book, special),
}));
}
function cityMapBookEntryRow(entry) {
return CITY_MAP_TOP_ROW_CATEGORY_SET.has(entry?.category) ? 0 : 1;
}
function cityMapCategoryOrder(category, special) {
const order = CITY_MAP_CATEGORY_ORDER_INDEX_BY_SPECIAL.get(special?.id) || CITY_MAP_CATEGORY_ORDER_INDEX;
return order.get(category) ?? order.size;
}
function orderCityMapBookEntries(entries, special) {
return [...entries].sort((a, b) => {
const rowDelta = cityMapBookEntryRow(a) - cityMapBookEntryRow(b);
if (rowDelta) return rowDelta;
const categoryDelta = cityMapCategoryOrder(a.category, special) - cityMapCategoryOrder(b.category, special);
if (categoryDelta) return categoryDelta;
return a.sourceIndex - b.sourceIndex;
});
}
function specialBookSpreadRows(entries) {
const topEntries = [];
const bottomEntries = [];
entries.forEach((entry) => {
(cityMapBookEntryRow(entry) === 0 ? topEntries : bottomEntries).push(entry);
});
const rows = [topEntries.length, bottomEntries.length];
rows.topGroups = cityMapEntryGroups(topEntries);
rows.bottomGroups = cityMapEntryGroups(bottomEntries);
return rows;
}
function cityMapEntryGroups(entries) {
const groups = [];
entries.forEach((entry) => {
const current = groups[groups.length - 1];
if (current?.category === entry.category) {
current.count += 1;
return;
}
groups.push({ category: entry.category, count: 1 });
});
return groups;
}
function specialBookStackKey(specialId, category) {
return `${specialId}--${category}`;
}
function activeSpecialStackIndex(stackKey, count) {
return clamp(state.specialStackIndexes.get(stackKey) || 0, 0, Math.max(count - 1, 0));
}
function activeSpecialBookEntry(group) {
return group.entries[activeSpecialStackIndex(specialBookStackKey(activeSpecial()?.id || "", group.category), group.entries.length)] || group.entries[0];
}
function visibleSpecialBookStackEntries(special, group) {
const stackKey = specialBookStackKey(special.id, group.category);
const activeIndex = activeSpecialStackIndex(stackKey, group.entries.length);
const activeEntry = group.entries[activeIndex] || group.entries[0];
const entries = [{ entry: activeEntry, layer: 0 }];
const peekCount = Math.min(group.entries.length - 1, 3);
for (let layer = 1; layer <= peekCount; layer += 1) {
const targetIndex = (activeIndex + layer) % group.entries.length;
const entry = group.entries[targetIndex];
if (entry) entries.push({ entry, layer });
}
return entries.filter((item) => item.entry?.book && !item.entry.book.isPlaceholder);
}
function specialBookConnectorTarget(point, layer) {
return {
x: point.x + layer * 20,
y: point.y + layer * 12,
};
}
function appendSpecialBookConnectors(fragment, special, group, point, index, hoverKey, origin, projection, stageRect) {
visibleSpecialBookStackEntries(special, group).forEach(({ entry, layer }, visibleIndex) => {
const anchor = specialBookAnchor(special, entry.book, origin, index + visibleIndex, stageRect, projection);
const target = specialBookConnectorTarget(point, layer);
fragment.append(
createSvg("path", {
d: specialConnectorPath(anchor, target, index + visibleIndex),
class: `special-book-line${layer ? " special-book-line--peek" : ""}`,
"data-book-key": hoverKey,
"data-layer": layer,
style: `--book-delay: ${Math.min((index + visibleIndex) * 34, 260)}ms`,
}),
);
fragment.append(createSpecialBookAnchorElement(anchor, hoverKey, index + visibleIndex, layer));
});
}
function prepareSpecialOverlay(stageRect) {
elements.specialOverlaySvg.setAttribute("width", `${Math.max(stageRect.width, 1)}`);
elements.specialOverlaySvg.setAttribute("height", `${Math.max(stageRect.height, 1)}`);
elements.specialOverlaySvg.setAttribute("viewBox", `0 0 ${Math.max(stageRect.width, 1)} ${Math.max(stageRect.height, 1)}`);
}
function resolveSpecialBookPoints(points, origin, stageRect, special, mode = "book") {
const resolved = points.map((point) => ({ ...point }));
const bounds = specialBookLayoutBounds(stageRect, special);
const coreAvoidanceRect = mode === "book" ? null : specialBookCoreAvoidanceRect(stageRect, special, mode);
const compactCityBook = special?.cityMap && mode === "book" && (stageRect.width < 1500 || stageRect.height < 1120);
const box = {
width: mode === "stack" ? (stageRect.width < 760 ? 174 : 210) : (stageRect.width < 760 ? 124 : (compactCityBook ? 164 : 210)),
height: mode === "stack" ? (stageRect.width < 760 ? 252 : 420) : (stageRect.width < 760 ? 176 : (compactCityBook ? 236 : 300)),
};
const layoutBox = specialBookStackCollisionBox(stageRect, mode, box);
const minY = mode === "stack"
? Math.max(bounds.minY, stageRect.width < 760 ? 134 : 232)
: Math.max(bounds.minY, special?.cityMap ? layoutBox.height / 2 + 24 : bounds.minY);
const maxY = mode === "stack"
? Math.min(bounds.maxY, stageRect.height - (stageRect.width < 760 ? 168 : 230))
: Math.min(bounds.maxY, special?.cityMap ? stageRect.height - layoutBox.height / 2 - (stageRect.width < 760 ? 112 : 40) : bounds.maxY);
const placementBounds = specialBookPlacementBounds(stageRect, special, mode, bounds, layoutBox);
const rowPoints = specialBookTwoRowPoints(resolved.length, stageRect, special, placementBounds, minY, Math.max(maxY, minY), layoutBox, coreAvoidanceRect, mode);
if (rowPoints) resolved.splice(0, resolved.length, ...rowPoints);
for (let iteration = 0; iteration < 10; iteration += 1) {
let changed = false;
for (let i = 0; i < resolved.length; i += 1) {
for (let j = i + 1; j < resolved.length; j += 1) {
const a = resolved[i];
const b = resolved[j];
const dx = b.x - a.x || 0.1;
const dy = b.y - a.y || 0.1;
const overlapX = layoutBox.width - Math.abs(dx);
const overlapY = layoutBox.height - Math.abs(dy);
if (overlapX <= 0 || overlapY <= 0) continue;
changed = true;
if (overlapX < overlapY) {
const push = (overlapX / 2 + 8) * Math.sign(dx);
a.x -= push;
b.x += push;
} else {
const push = (overlapY / 2 + 8) * Math.sign(dy);
a.y -= push;
b.y += push;
}
}
}
for (const point of resolved) {
point.x = clamp(point.x, placementBounds.minX, placementBounds.maxX);
point.y = clamp(point.y, minY, Math.max(maxY, minY));
if (Math.hypot(point.x - origin.x, point.y - origin.y) < 118) {
const angle = Math.atan2(point.y - origin.y, point.x - origin.x);
point.x = origin.x + Math.cos(angle) * 118;
point.y = origin.y + Math.sin(angle) * 118;
}
if (avoidSpecialBookCore(point, coreAvoidanceRect, placementBounds, minY, Math.max(maxY, minY), layoutBox)) {
changed = true;
}
}
if (!changed) break;
}
return resolved;
}
function specialBookStackCollisionBox(stageRect, mode, box) {
if (mode === "book") {
return {
width: box.width + (stageRect.width < 760 ? 32 : 14),
height: box.height + (stageRect.width < 760 ? 48 : 24),
};
}
if (mode !== "stack") return box;
return {
width: stageRect.width < 760 ? Math.max(box.width, 224) : Math.max(box.width, 244),
height: stageRect.width < 760 ? Math.max(box.height, 286) : Math.max(box.height, 350),
};
}
function specialBookPlacementBounds(stageRect, special, mode, bounds, box) {
if (!special?.cityMap || (mode !== "stack" && mode !== "book")) return bounds;
const horizontalBleed = mode === "book"
? (stageRect.width < 760 ? box.width * 0.08 : box.width * 0.22)
: (stageRect.width < 760 ? box.width * 0.18 : box.width * 0.42);
const edgePadding = stageRect.width < 760 ? 18 : 24;
const minVisibleCenter = edgePadding + box.width / 2;
const maxVisibleCenter = Math.max(minVisibleCenter, stageRect.width - edgePadding - box.width / 2);
return {
...bounds,
minX: Math.max(minVisibleCenter, bounds.minX - horizontalBleed),
maxX: Math.min(maxVisibleCenter, bounds.maxX + horizontalBleed),
};
}
function specialBookTwoRowPoints(count, stageRect, special, bounds, minY, maxY, box, coreAvoidanceRect, mode) {
if (!special?.cityMap || count <= 0) return null;
const topCount = Math.ceil(count / 2);
const bottomCount = count - topCount;
const rowGap = stageRect.width < 760 ? 18 : 28;
const musicSafeBottom = stageRect.width < 760 ? 150 : 118;
const safeMaxY = Math.min(maxY, stageRect.height - musicSafeBottom - box.height / 2);
if (mode === "book") {
const spreadPoints = specialBookSpreadSlots(count, stageRect, bounds, minY, safeMaxY, box, special?.cityMapSpreadRows);
if (spreadPoints.length >= count) return spreadPoints;
}
if (!coreAvoidanceRect) return null;
const rowLift = stageRect.width < 760 ? 18 : 34;
const rowJitter = stageRect.width < 760 ? 8 : 22;
const topY = clamp(coreAvoidanceRect.top - rowGap - box.height / 2, minY + rowJitter, Math.max(minY + rowJitter, safeMaxY));
const bottomY = clamp(
coreAvoidanceRect.bottom + rowGap + box.height / 2 - rowLift,
minY,
Math.max(minY, safeMaxY - rowJitter),
);
const perimeterPoints = specialBookPerimeterSlots(count, stageRect, bounds, minY, safeMaxY, box, coreAvoidanceRect);
if (perimeterPoints.length >= count) return perimeterPoints;
const rows = [
{ count: topCount, start: 0, y: topY, jitter: -rowJitter },
{ count: bottomCount, start: topCount, y: bottomY, jitter: rowJitter },
];
const points = [];
rows.forEach((row) => {
if (row.count <= 0) return;
const usableMinX = bounds.minX + box.width * 0.38;
const usableMaxX = bounds.maxX - box.width * 0.38;
for (let index = 0; index < row.count; index += 1) {
const ratio = row.count === 1 ? 0.5 : (index + 0.5) / row.count;
const staggerY = row.count === 1 ? 0 : (index % 2 === 0 ? row.jitter : -row.jitter);
points[row.start + index] = {
x: usableMinX + (usableMaxX - usableMinX) * ratio,
y: clamp(row.y + staggerY, minY, Math.max(minY, safeMaxY)),
};
}
});
return points;
}
function specialBookSpreadSlots(count, stageRect, bounds, minY, safeMaxY, box, rowsOverride) {
const [topCount, bottomCount] = Array.isArray(rowsOverride)
? rowsOverride
: [Math.ceil(count / 2), count - Math.ceil(count / 2)];
const rowJitter = stageRect.width < 760 ? 8 : 18;
const rightControlSafe = stageRect.width < 760 ? 132 : 300;
const topRowMaxX = Math.min(bounds.maxX, stageRect.width - rightControlSafe - box.width / 2);
const centerSafeGap = {
left: stageRect.width * (stageRect.width < 760 ? 0.3 : 0.38),
right: stageRect.width * (stageRect.width < 760 ? 0.7 : 0.65),
};
const bottomLeftCount = Math.floor(bottomCount / 2);
const bottomRightCount = bottomCount - bottomLeftCount;
const cityMapRowGroupGap = stageRect.width < 760 ? 0.22 : 0.64;
const bottomY = clamp(safeMaxY + box.height * 0.16, minY, safeMaxY + box.height * 0.2);
const bottomRowJitter = stageRect.width < 760 ? 24 : 80;
const topStaggerPattern = [
rowJitter * 1.55,
rowJitter * 3.9,
-rowJitter * 0.9,
rowJitter * 2.9,
-rowJitter * 1.45,
rowJitter * 4.55,
];
const bottomStaggerPattern = [
-bottomRowJitter,
rowJitter * 2.35,
rowJitter * 5.5,
rowJitter * 5.1,
bottomRowJitter * 0.98,
-bottomRowJitter * 0.7,
rowJitter * 2.55,
];
const rows = [
{ count: topCount, start: 0, y: minY, jitter: -rowJitter },
{ count: bottomCount, start: topCount, y: bottomY, jitter: rowJitter },
];
const points = [];
rows.forEach((row) => {
if (row.count <= 0) return;
const usableMinX = bounds.minX;
const usableMaxX = row.start === 0 ? topRowMaxX : bounds.maxX;
for (let index = 0; index < row.count; index += 1) {
const rowGroups = row.start === 0 ? rowsOverride?.topGroups : rowsOverride?.bottomGroups;
let ratio = cityMapGroupedRatio(index, row.count, rowGroups, cityMapRowGroupGap);
let xMin = usableMinX;
let xMax = usableMaxX;
if (row.start !== 0) {
const bottomIndex = index;
const side = bottomIndex < bottomLeftCount ? "left" : "right";
const sideIndex = side === "left" ? bottomIndex : bottomIndex - bottomLeftCount;
const sideCount = side === "left" ? bottomLeftCount : bottomRightCount;
const sideGroups = cityMapSplitGroups(rowGroups, side === "left" ? 0 : bottomLeftCount, sideCount);
const leftMaxX = Math.min(bounds.maxX, centerSafeGap.left - box.width * 0.55);
const rightMinX = Math.max(bounds.minX, centerSafeGap.right + box.width * 0.55);
ratio = cityMapGroupedRatio(sideIndex, sideCount, sideGroups, cityMapRowGroupGap);
xMin = side === "left" ? usableMinX : rightMinX;
xMax = side === "left" ? leftMaxX : usableMaxX;
}
const staggerPattern = row.start === 0 ? topStaggerPattern : bottomStaggerPattern;
const staggerY = row.count === 1 ? 0 : staggerPattern[index % staggerPattern.length];
points[row.start + index] = {
x: clamp(xMin + Math.max(xMax - xMin, 0) * ratio, bounds.minX, bounds.maxX),
y: clamp(row.y + staggerY, minY, Math.max(safeMaxY, bottomY + bottomRowJitter * 1.35)),
};
}
});
return points.slice(0, count);
}
function cityMapGroupedRatio(index, count, groups, gap) {
if (count <= 1) return 0.5;
const usableGroups = Array.isArray(groups) && groups.length ? groups : [{ count }];
let groupStart = 0;
let priorGaps = 0;
for (const group of usableGroups) {
const groupEnd = groupStart + group.count;
if (index < groupEnd) break;
priorGaps += 1;
groupStart = groupEnd;
}
const totalUnits = count + Math.max(usableGroups.length - 1, 0) * gap;
return (index + 0.5 + priorGaps * gap) / totalUnits;
}
function cityMapSplitGroups(groups, start, count) {
if (!Array.isArray(groups) || count <= 0) return [];
const result = [];
let cursor = 0;
const end = start + count;
groups.forEach((group) => {
const groupStart = cursor;
const groupEnd = cursor + group.count;
const overlapStart = Math.max(start, groupStart);
const overlapEnd = Math.min(end, groupEnd);
if (overlapEnd > overlapStart) {
result.push({ category: group.category, count: overlapEnd - overlapStart });
}
cursor = groupEnd;
});
return result;
}
function specialBookPerimeterSlots(count, stageRect, bounds, minY, safeMaxY, box, coreAvoidanceRect) {
const halfWidth = box.width / 2;
const halfHeight = box.height / 2;
const gap = stageRect.width < 760 ? 22 : 44;
const looseGap = stageRect.width < 760 ? 26 : 58;
const sideInset = stageRect.width < 760 ? 8 : 12;
const coreHeight = coreAvoidanceRect.bottom - coreAvoidanceRect.top;
const sideTop = clamp(coreAvoidanceRect.top + coreHeight * 0.2, minY + halfHeight * 0.2, safeMaxY - halfHeight * 0.16);
const sideBottom = clamp(coreAvoidanceRect.bottom - coreHeight * 0.16, minY + halfHeight * 0.16, safeMaxY - halfHeight * 0.2);
const sideMid = clamp(coreAvoidanceRect.centerY, minY + halfHeight * 0.2, safeMaxY - halfHeight * 0.2);
const leftInnerX = clamp(coreAvoidanceRect.left - looseGap - halfWidth, bounds.minX + sideInset, bounds.maxX - sideInset);
const rightInnerX = clamp(coreAvoidanceRect.right + looseGap + halfWidth, bounds.minX + sideInset, bounds.maxX - sideInset);
const outerLeftX = clamp(bounds.minX + sideInset, bounds.minX + sideInset, bounds.maxX - sideInset);
const outerRightX = clamp(bounds.maxX - sideInset, bounds.minX + sideInset, bounds.maxX - sideInset);
const topY = clamp(coreAvoidanceRect.top - gap - halfHeight, minY, safeMaxY);
const bottomY = clamp(coreAvoidanceRect.bottom + gap + halfHeight, minY, safeMaxY);
const topSlots = [
{ x: outerLeftX, y: topY },
{ x: outerRightX, y: topY },
{ x: leftInnerX, y: topY },
{ x: rightInnerX, y: topY },
];
const sideSlots = [
{ x: outerLeftX, y: topY },
{ x: outerRightX, y: topY },
{ x: leftInnerX, y: bottomY },
{ x: rightInnerX, y: bottomY },
{ x: leftInnerX, y: topY },
{ x: rightInnerX, y: topY },
{ x: outerLeftX, y: bottomY },
{ x: outerRightX, y: bottomY },
{ x: leftInnerX, y: sideMid },
{ x: rightInnerX, y: sideMid },
{ x: outerLeftX, y: sideTop },
{ x: outerRightX, y: sideTop },
{ x: outerLeftX, y: sideBottom },
{ x: outerRightX, y: sideBottom },
];
const bottomSlots = [
{ x: outerLeftX, y: bottomY },
{ x: outerRightX, y: bottomY },
{ x: leftInnerX, y: bottomY },
{ x: rightInnerX, y: bottomY },
];
const occupied = [];
return [...sideSlots, ...topSlots, ...bottomSlots]
.map((candidate) => ({
x: clamp(candidate.x, bounds.minX, bounds.maxX),
y: clamp(candidate.y, minY, safeMaxY),
}))
.filter((candidate) => !pointBoxOverlapsRect(candidate, box, coreAvoidanceRect))
.filter((candidate) => {
if (pointBoxOverlapsAny(candidate, box, occupied)) return false;
occupied.push(pointBox(candidate, box));
return true;
})
.slice(0, count);
}
function specialBookCoreAvoidanceRect(stageRect, special, mode) {
if (!special?.cityMap || (mode !== "stack" && mode !== "book")) return null;
const layout = special?.bookLayout?.coreAvoidance || {};
if (layout === false) return null;
const mobile = stageRect.width < 760;
const defaults = mobile
? { widthRatio: 0.5, heightRatio: 0.38, minWidth: 280, maxWidth: 390, minHeight: 210, maxHeight: 290, xRatio: 0.5, yRatio: 0.53 }
: { widthRatio: 0.48, heightRatio: 0.42, minWidth: 620, maxWidth: 1040, minHeight: 310, maxHeight: 470, xRatio: 0.5, yRatio: 0.52 };
const widthRatio = layout.widthRatio ?? defaults.widthRatio;
const heightRatio = layout.heightRatio ?? defaults.heightRatio;
const width = layout.width ?? clamp(stageRect.width * widthRatio, layout.minWidth ?? defaults.minWidth, layout.maxWidth ?? defaults.maxWidth);
const height = layout.height ?? clamp(stageRect.height * heightRatio, layout.minHeight ?? defaults.minHeight, layout.maxHeight ?? defaults.maxHeight);
const centerX = layout.x ?? stageRect.width * (layout.xRatio ?? defaults.xRatio);
const centerY = layout.y ?? stageRect.height * (layout.yRatio ?? defaults.yRatio);
return {
left: centerX - width / 2,
right: centerX + width / 2,
top: centerY - height / 2,
bottom: centerY + height / 2,
centerX,
centerY,
};
}
function avoidSpecialBookCore(point, coreAvoidanceRect, bounds, minY, maxY, box) {
if (!coreAvoidanceRect || !pointBoxOverlapsRect(point, box, coreAvoidanceRect)) return false;
const margin = 36;
const halfWidth = box.width / 2;
const halfHeight = box.height / 2;
const candidates = [
{ x: coreAvoidanceRect.left - margin - halfWidth, y: clamp(point.y, minY, maxY), side: "left" },
{ x: coreAvoidanceRect.right + margin + halfWidth, y: clamp(point.y, minY, maxY), side: "right" },
{ x: clamp(point.x, bounds.minX, bounds.maxX), y: coreAvoidanceRect.top - margin - halfHeight, side: "top" },
{ x: clamp(point.x, bounds.minX, bounds.maxX), y: coreAvoidanceRect.bottom + margin + halfHeight, side: "bottom" },
]
.map((candidate) => ({
...candidate,
x: clamp(candidate.x, bounds.minX, bounds.maxX),
y: clamp(candidate.y, minY, maxY),
}))
.filter((candidate) => !pointBoxOverlapsRect(candidate, box, coreAvoidanceRect));
if (!candidates.length) return false;
const preferredSide = Math.abs(point.x - coreAvoidanceRect.centerX) >= Math.abs(point.y - coreAvoidanceRect.centerY)
? (point.x < coreAvoidanceRect.centerX ? "left" : "right")
: (point.y < coreAvoidanceRect.centerY ? "top" : "bottom");
candidates.sort((a, b) => {
const aBias = a.side === preferredSide ? 0 : 160;
const bBias = b.side === preferredSide ? 0 : 160;
return Math.hypot(a.x - point.x, a.y - point.y) + aBias - (Math.hypot(b.x - point.x, b.y - point.y) + bBias);
});
const moved = Math.abs(point.x - candidates[0].x) > 0.1 || Math.abs(point.y - candidates[0].y) > 0.1;
point.x = candidates[0].x;
point.y = candidates[0].y;
return moved;
}
function pointBoxOverlapsRect(point, box, rect) {
const candidate = pointBox(point, box);
return (
candidate.right > rect.left
&& candidate.left < rect.right
&& candidate.bottom > rect.top
&& candidate.top < rect.bottom
);
}
function pointBoxOverlapsAny(point, box, rects) {
return rects.some((rect) => pointBoxOverlapsRect(point, box, rect));
}
function pointBox(point, box) {
const halfWidth = box.width / 2;
const halfHeight = box.height / 2;
return {
left: point.x - halfWidth,
right: point.x + halfWidth,
top: point.y - halfHeight,
bottom: point.y + halfHeight,
};
}
function specialBookLayoutBounds(stageRect, special) {
const layout = special?.bookLayout || {};
const mobile = stageRect.width < 760;
const cityMap = Boolean(special?.cityMap);
const sideSafe = mobile ? CITY_SPECIAL_BOOK_SAFE_ZONE.mobileSide : CITY_SPECIAL_BOOK_SAFE_ZONE.side;
const cityTopSafe = mobile ? CITY_SPECIAL_BOOK_SAFE_ZONE.mobileTop : CITY_SPECIAL_BOOK_SAFE_ZONE.top;
const cityBottomSafe = mobile ? CITY_SPECIAL_BOOK_SAFE_ZONE.mobileBottom : CITY_SPECIAL_BOOK_SAFE_ZONE.bottom;
const minX = layout.minX ?? sideSafe;
const maxRight = layout.maxRight ?? sideSafe;
const minY = layout.minY ?? (cityMap ? cityTopSafe : 112);
const maxBottom = layout.maxBottom ?? (cityMap ? cityBottomSafe : 92);
return {
minX,
maxX: Math.max(stageRect.width - maxRight, minX),
minY,
maxY: Math.max(stageRect.height - maxBottom, minY),
};
}
function specialBookPoint(origin, index, stageRect, special, mode = "book") {
const offsets = mode === "stack" ? SPECIAL_CATEGORY_STACK_OFFSETS : SPECIAL_BOOK_OFFSETS;
const [dx, dy] = offsets[index % offsets.length];
const scale = stageRect.width < 760 ? 0.54 : stageRect.width < 1080 ? 0.76 : 1;
const bounds = specialBookLayoutBounds(stageRect, special);
const minY = mode === "stack" ? Math.max(bounds.minY, stageRect.width < 760 ? 134 : 232) : bounds.minY;
const maxY = mode === "stack" ? Math.min(bounds.maxY, stageRect.height - (stageRect.width < 760 ? 168 : 230)) : bounds.maxY;
return {
x: clamp(origin.x + dx * scale, bounds.minX, bounds.maxX),
y: clamp(origin.y + dy * scale, minY, Math.max(maxY, minY)),
};
}
function specialBookAnchor(special, book, origin, index, stageRect, projection) {
const bookAnchor = parseBookAnchorCoordinate(book);
if (bookAnchor) {
if (special?.cityMap) {
const point = projectCityMapPoint(bookAnchor, stageRect, special);
return {
x: clamp(point.x, 24, Math.max(stageRect.width - 24, 24)),
y: clamp(point.y, 24, Math.max(stageRect.height - 24, 24)),
};
}
const [x, y, visible] = projectPoint(bookAnchor, projection);
if (visible) {
const point = svgToStageFreePoint(x, y);
return {
x: clamp(point.x, 24, Math.max(stageRect.width - 24, 24)),
y: clamp(point.y, 24, Math.max(stageRect.height - 24, 24)),
};
}
}
const boundary = mapData.specialBoundaries.get(special.cityId);
const anchors = boundary?.anchors || [];
if (anchors.length) {
const anchorCoordinate = anchors[index % anchors.length];
const [x, y, visible] = projectPoint(anchorCoordinate, projection);
if (visible) {
const point = svgToStageFreePoint(x, y);
return {
x: clamp(point.x, 24, Math.max(stageRect.width - 24, 24)),
y: clamp(point.y, 24, Math.max(stageRect.height - 24, 24)),
};
}
}
const angle = (index / Math.max(SPECIAL_BOOK_OFFSETS.length, 1)) * Math.PI * 2 - Math.PI / 2;
const ring = stageRect.width < 760 ? 12 : 18;
return {
x: origin.x + Math.cos(angle) * ring,
y: origin.y + Math.sin(angle) * ring,
};
}
function parseBookAnchorCoordinate(book) {
const coordinate = book?.["特辑锚点坐标"] || book?.specialAnchorCoordinate;
if (Array.isArray(coordinate) && coordinate.length >= 2) {
const lon = Number(coordinate[0]);
const lat = Number(coordinate[1]);
return Number.isFinite(lon) && Number.isFinite(lat) ? [lon, lat] : null;
}
if (typeof coordinate === "string") {
const [lon, lat] = coordinate.split(/[,,\s]+/).map(Number);
return Number.isFinite(lon) && Number.isFinite(lat) ? [lon, lat] : null;
}
return null;
}
function projectCityMapPoint(coordinate, stageRect, special) {
const tileProvider = resolveCityTileProvider(special, currentTileRuntimeConfig());
const tileZoom = special.tileZoom || 12;
const tileSize = tileProvider.tileSize || 256;
const cityMapScale = Number(special.cityMapScale) > 0 ? Number(special.cityMapScale) : 1;
const centerPixel = mercatorPixel(state.center, tileZoom, tileSize);
const anchorPixel = mercatorPixel(coordinate, tileZoom, tileSize);
return {
x: stageRect.width / 2 + (anchorPixel.x - centerPixel.x) * cityMapScale,
y: stageRect.height / 2 + (anchorPixel.y - centerPixel.y) * cityMapScale,
};
}
function cityMapConnectorPathOptions(point, stageRect) {
return {
verticalCurveDirection: point.y < stageRect.height / 2 ? 1 : -1,
};
}
function specialConnectorPath(origin, point, index, options = {}) {
const dx = point.x - origin.x;
const dy = point.y - origin.y;
const distance = Math.hypot(dx, dy) || 1;
if (options.verticalCurveDirection) {
const curve = Math.min(132, Math.max(56, distance * 0.22)) * options.verticalCurveDirection;
const c1 = {
x: origin.x + dx * 0.3,
y: origin.y + dy * 0.26 + curve,
};
const c2 = {
x: origin.x + dx * 0.68,
y: origin.y + dy * 0.72 + curve,
};
return `M ${origin.x.toFixed(2)} ${origin.y.toFixed(2)} C ${c1.x.toFixed(2)} ${c1.y.toFixed(2)} ${c2.x.toFixed(2)} ${c2.y.toFixed(2)} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
}
const normalX = -dy / distance;
const normalY = dx / distance;
const curve = Math.min(112, Math.max(42, distance * 0.2)) * (index % 2 === 0 ? 1 : -1);
const c1 = {
x: origin.x + dx * 0.24 + normalX * curve,
y: origin.y + dy * 0.24 + normalY * curve,
};
const c2 = {
x: origin.x + dx * 0.72 + normalX * curve * 0.56,
y: origin.y + dy * 0.72 + normalY * curve * 0.56,
};
return `M ${origin.x.toFixed(2)} ${origin.y.toFixed(2)} C ${c1.x.toFixed(2)} ${c1.y.toFixed(2)} ${c2.x.toFixed(2)} ${c2.y.toFixed(2)} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
}
function createSpecialBookAnchorElement(anchor, hoverKey, index, layer = 0) {
const group = createSvg("g", {
class: `special-book-anchor${layer ? " special-book-anchor--peek" : ""}`,
"data-book-key": hoverKey,
"data-layer": layer,
transform: `translate(${anchor.x.toFixed(2)} ${anchor.y.toFixed(2)})`,
style: `--book-delay: ${Math.min(index * 34, 260)}ms`,
});
group.append(
createSvg("circle", { class: "special-book-anchor__halo", r: 5 }),
createSvg("circle", { class: "special-book-anchor__dot", r: 2.5 }),
);
return group;
}
function createSpecialBookStackElement(special, group, point, index, hoverKey) {
const stackKey = specialBookStackKey(special.id, group.category);
const activeIndex = activeSpecialStackIndex(stackKey, group.entries.length);
const activeEntry = group.entries[activeIndex] || group.entries[0];
const book = activeEntry.book;
const isMulti = group.entries.length > 1;
const root = document.createElement("div");
root.className = `special-book special-book-stack${isMulti ? " is-multi" : ""}${state.activeSpecialStackKey === stackKey ? " is-stack-active" : ""}${book.isPlaceholder ? " special-book--empty" : ""}`;
root.dataset.bookKey = hoverKey;
root.dataset.stackKey = stackKey;
root.style.setProperty("--book-x", `${point.x.toFixed(2)}px`);
root.style.setProperty("--book-y", `${point.y.toFixed(2)}px`);
root.style.setProperty("--book-delay", `${Math.min(index * 46, 260)}ms`);
root.style.setProperty("--stack-count", String(group.entries.length));
const peekCount = Math.min(group.entries.length - 1, 3);
for (let peekIndex = peekCount; peekIndex >= 1; peekIndex -= 1) {
const targetIndex = (activeIndex + peekIndex) % group.entries.length;
const peekEntry = group.entries[targetIndex];
const peek = document.createElement("button");
peek.type = "button";
peek.className = `special-book__peek special-book__peek--${peekIndex}`;
peek.setAttribute("aria-label", `切到 ${peekEntry?.book?.titleZh || group.category}`);
peek.append(createSpecialBookmarkContent(special, peekEntry.book, group.category));
peek.addEventListener("click", (event) => {
event.stopPropagation();
state.activeSpecialStackKey = stackKey;
state.specialStackIndexes.set(stackKey, targetIndex);
trackSpecialStackSwitched(special, group, stackKey, targetIndex, "special_stack_peek");
playUiSound("pageFlick");
updateSpecialBookStack(stackKey);
});
root.append(peek);
}
const button = document.createElement("button");
button.className = "special-book__hit";
button.type = "button";
button.setAttribute("aria-label", isMulti ? `${group.category}${activeIndex + 1}/${group.entries.length}${book.titleZh || special.specialName}` : `查看 ${book.titleZh || special.specialName}`);
button.append(createSpecialBookmarkContent(special, book, group.category));
root.append(button);
root.addEventListener("pointerenter", () => setSpecialBookHover(hoverKey, true));
root.addEventListener("pointerleave", () => setSpecialBookHover(hoverKey, false));
button.addEventListener("focus", () => setSpecialBookHover(hoverKey, true));
button.addEventListener("blur", () => setSpecialBookHover(hoverKey, false));
button.addEventListener("click", () => {
if (book.isPlaceholder) return;
if (isMulti && state.activeSpecialStackKey !== stackKey) {
state.activeSpecialStackKey = stackKey;
trackSpecialStackSwitched(special, group, stackKey, activeIndex, "special_stack_focus");
playUiSound("softTap");
updateSpecialBookStack(stackKey);
return;
}
openSpecialBook(special, book, point, activeEntry.sourceIndex);
});
return root;
}
function createSpecialBookmarkContent(special, book, category = specialBookCategory(book, special)) {
const bookmark = document.createElement("span");
bookmark.className = "special-book__bookmark";
const cover = document.createElement("span");
cover.className = "special-book__cover";
if (book.coverUrl) {
const image = document.createElement("img");
cover.classList.add("is-cover-loading");
image.decoding = "async";
image.loading = "eager";
image.addEventListener("load", () => {
cover.classList.remove("is-cover-loading");
cover.classList.add("is-cover-ready");
scheduleTicketCutPaths(bookmark);
}, { once: true });
image.addEventListener("error", () => {
cover.classList.remove("is-cover-loading");
cover.classList.add("is-cover-failed");
image.hidden = true;
const fallback = document.createElement("span");
fallback.className = "special-book__fallback";
fallback.textContent = fallbackMark(book, special);
cover.append(fallback);
scheduleTicketCutPaths(bookmark);
}, { once: true });
image.src = book.coverUrl;
image.alt = "";
cover.append(image);
} else {
const fallback = document.createElement("span");
fallback.className = "special-book__fallback";
fallback.textContent = fallbackMark(book, special);
cover.append(fallback);
}
const body = document.createElement("span");
body.className = "special-book__body";
const context = document.createElement("span");
context.className = "special-book__context";
const contextZh = document.createElement("span");
contextZh.className = "special-book__context-zh";
contextZh.textContent = category;
const contextDivider = document.createElement("span");
contextDivider.className = "special-book__context-divider";
contextDivider.textContent = "|";
const contextEn = document.createElement("span");
contextEn.className = "special-book__context-en";
contextEn.textContent = specialBookCategoryEn(category);
context.append(contextZh, contextDivider, contextEn);
const title = document.createElement("span");
title.className = "special-book__title";
title.textContent = book.titleZh || "Curating";
const rule = document.createElement("span");
rule.className = "special-book__rule";
rule.setAttribute("aria-hidden", "true");
const meta = document.createElement("span");
meta.className = "special-book__meta";
meta.textContent = bookMeta(book);
meta.hidden = !meta.textContent;
const quote = document.createElement("span");
quote.className = "special-book__quote";
quote.textContent = shortBookText(book);
quote.hidden = !quote.textContent;
body.append(context, title, rule, meta, quote);
bookmark.append(cover, body);
return bookmark;
}
function createSpecialBookElement(special, book, point, index, hoverKey, mode = "special", category) {
hoverKey = hoverKey ?? specialBookKey(special.id, index);
category = category ?? specialBookCategory(book, special);
const button = document.createElement("button");
button.className = `special-book special-book--${mode}${book.isPlaceholder ? " special-book--empty" : ""}`;
button.type = "button";
button.dataset.bookKey = hoverKey;
button.dataset.bookId = book.id || "";
button.style.setProperty("--book-x", `${point.x.toFixed(2)}px`);
button.style.setProperty("--book-y", `${point.y.toFixed(2)}px`);
button.style.setProperty("--book-delay", `${Math.min(index * 34, 260)}ms`);
button.setAttribute("aria-label", book.titleZh ? `查看 ${book.titleZh}` : special.specialName);
const hit = document.createElement("span");
hit.className = "special-book__hit";
hit.append(createSpecialBookmarkContent(special, book, category));
button.append(hit);
button.addEventListener("pointerdown", (event) => beginSpecialBookDrag(event, special, book, point, hoverKey));
button.addEventListener("pointerenter", () => setSpecialBookHover(hoverKey, true));
button.addEventListener("pointerleave", () => setSpecialBookHover(hoverKey, false));
button.addEventListener("focus", () => setSpecialBookHover(hoverKey, true));
button.addEventListener("blur", () => setSpecialBookHover(hoverKey, false));
button.addEventListener("click", (event) => {
if (shouldSuppressSpecialBookClick(hoverKey)) {
event.preventDefault();
event.stopPropagation();
return;
}
if (book.isPlaceholder) return;
openSpecialBook(special, book, point, index);
});
return button;
}
function beginSpecialBookDrag(event, special, book, point, hoverKey) {
if (!special?.cityMap || book.isPlaceholder || event.button > 0) return;
event.stopPropagation();
const element = event.currentTarget;
const stageRect = elements.mapStage.getBoundingClientRect();
const rect = element.getBoundingClientRect();
state.specialBookDrag = {
pointerId: event.pointerId,
hoverKey,
draftKey: specialBookDraftKey(special.id, book.id),
element,
point,
startX: event.clientX,
startY: event.clientY,
startPoint: { ...point },
width: rect.width,
height: rect.height,
stageRect,
bounds: cityMapBookDragBounds(special, stageRect, rect),
moved: false,
};
element.classList.add("is-dragging");
element.setPointerCapture?.(event.pointerId);
window.addEventListener("pointermove", moveSpecialBookDrag);
window.addEventListener("pointerup", endSpecialBookDrag, { once: true });
window.addEventListener("pointercancel", endSpecialBookDrag, { once: true });
}
function moveSpecialBookDrag(event) {
const drag = state.specialBookDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
event.preventDefault();
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (!drag.moved && Math.hypot(dx, dy) < 4) return;
drag.moved = true;
const rawPoint = {
x: clamp(drag.startPoint.x + dx, drag.bounds.minX, drag.bounds.maxX),
y: clamp(drag.startPoint.y + dy, drag.bounds.minY, drag.bounds.maxY),
};
const snapped = applySnapToGuides(
{
id: drag.hoverKey,
x: rawPoint.x - drag.width / 2,
y: rawPoint.y - drag.height / 2,
width: drag.width,
height: drag.height,
},
currentSpecialBookDragItems(drag),
{ threshold: 10 },
);
const nextPoint = {
x: clamp(snapped.x + drag.width / 2, drag.bounds.minX, drag.bounds.maxX),
y: clamp(snapped.y + drag.height / 2, drag.bounds.minY, drag.bounds.maxY),
};
commitSpecialBookDragPoint(drag, avoidCityMapBookForbiddenRects(nextPoint, drag));
}
function cityMapBookDragBounds(special, stageRect, rect) {
const halfWidth = rect.width / 2;
const halfHeight = rect.height / 2;
const safeHalfWidth = special?.cityMap
? halfWidth * CITY_MAP_BOOK_HOVER_SAFE_SCALE + CITY_MAP_BOOK_FORBIDDEN_GAP
: halfWidth;
const safeHalfHeight = special?.cityMap
? halfHeight * CITY_MAP_BOOK_HOVER_SAFE_SCALE + CITY_MAP_BOOK_FORBIDDEN_GAP
: halfHeight;
const bottomForbiddenTop = cityMapBookBottomForbiddenTop(stageRect);
const minY = special?.cityMap
? cityMapBookTopForbiddenBottom(special) + safeHalfHeight
: halfHeight;
return {
minX: safeHalfWidth,
maxX: Math.max(safeHalfWidth, stageRect.width - safeHalfWidth),
minY,
maxY: Math.max(minY, bottomForbiddenTop - safeHalfHeight),
safeWidth: safeHalfWidth * 2,
safeHeight: safeHalfHeight * 2,
forbiddenRects: special?.cityMap ? cityMapBookForbiddenRects(stageRect) : [],
};
}
function cityMapBookBottomForbiddenTop(stageRect) {
const musicTop = elementStageTopEdge(elements.musicSignature, stageRect);
if (musicTop != null) return musicTop;
return stageRect.height - CITY_MAP_BOOK_BOTTOM_FORBIDDEN_TOP_OFFSET;
}
function cityMapBookForbiddenRects(stageRect) {
return [elements.specialBackButton]
.map((element) => elementStageRect(element, stageRect, 8))
.filter(Boolean);
}
function elementStageRect(element, stageRect, padding = 0) {
if (!element || element.hidden) return null;
const rect = element.getBoundingClientRect();
if (!rect.width || !rect.height || rect.right <= stageRect.left || rect.left >= stageRect.right) return null;
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return null;
return {
left: rect.left - stageRect.left - padding,
right: rect.right - stageRect.left + padding,
top: rect.top - stageRect.top - padding,
bottom: rect.bottom - stageRect.top + padding,
};
}
function avoidCityMapBookForbiddenRects(point, drag) {
let resolved = { ...point };
drag.bounds.forbiddenRects.forEach((forbiddenRect) => {
const safeSize = {
width: drag.bounds.safeWidth ?? drag.width,
height: drag.bounds.safeHeight ?? drag.height,
};
const box = pointBox(resolved, safeSize);
if (
box.right <= forbiddenRect.left
|| box.left >= forbiddenRect.right
|| box.bottom <= forbiddenRect.top
|| box.top >= forbiddenRect.bottom
) {
return;
}
const candidates = [
{ x: forbiddenRect.left - safeSize.width / 2, y: resolved.y },
{ x: forbiddenRect.right + safeSize.width / 2, y: resolved.y },
{ x: resolved.x, y: forbiddenRect.top - safeSize.height / 2 },
{ x: resolved.x, y: forbiddenRect.bottom + safeSize.height / 2 },
]
.map((candidate) => ({
x: clamp(candidate.x, drag.bounds.minX, drag.bounds.maxX),
y: clamp(candidate.y, drag.bounds.minY, drag.bounds.maxY),
}))
.filter((candidate) => {
const candidateBox = pointBox(candidate, safeSize);
return (
candidateBox.right <= forbiddenRect.left
|| candidateBox.left >= forbiddenRect.right
|| candidateBox.bottom <= forbiddenRect.top
|| candidateBox.top >= forbiddenRect.bottom
);
});
if (!candidates.length) return;
candidates.sort((a, b) => (
Math.hypot(a.x - resolved.x, a.y - resolved.y)
- Math.hypot(b.x - resolved.x, b.y - resolved.y)
));
resolved = candidates[0];
});
return resolved;
}
function commitSpecialBookDragPoint(drag, nextPoint) {
drag.point.x = nextPoint.x;
drag.point.y = nextPoint.y;
drag.element.style.setProperty("--book-x", `${nextPoint.x.toFixed(2)}px`);
drag.element.style.setProperty("--book-y", `${nextPoint.y.toFixed(2)}px`);
state.specialBookDraftPoints.set(drag.draftKey, { ...nextPoint });
updateDraggedSpecialBookConnector(drag.hoverKey, nextPoint);
}
function endSpecialBookDrag(event) {
const drag = state.specialBookDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
window.removeEventListener("pointermove", moveSpecialBookDrag);
drag.element.releasePointerCapture?.(event.pointerId);
drag.element.classList.remove("is-dragging");
if (drag.moved) {
state.specialBookSuppressedClickKey = drag.hoverKey;
window.setTimeout(() => {
if (state.specialBookSuppressedClickKey === drag.hoverKey) {
state.specialBookSuppressedClickKey = null;
}
}, 800);
}
state.specialBookDrag = null;
}
function shouldSuppressSpecialBookClick(hoverKey) {
if (state.specialBookSuppressedClickKey !== hoverKey) return false;
state.specialBookSuppressedClickKey = null;
return true;
}
function currentSpecialBookDragItems(drag) {
return Array.from(elements.specialBookLayer.querySelectorAll(".special-book[data-book-key]")).map((element) => {
if (element === drag.element) {
return {
id: drag.hoverKey,
x: drag.point.x - drag.width / 2,
y: drag.point.y - drag.height / 2,
width: drag.width,
height: drag.height,
};
}
const rect = element.getBoundingClientRect();
return {
id: element.dataset.bookKey,
x: rect.left - drag.stageRect.left,
y: rect.top - drag.stageRect.top,
width: rect.width,
height: rect.height,
};
});
}
function updateDraggedSpecialBookConnector(hoverKey, point) {
const data = state.specialBookRenderData.get(hoverKey);
if (!data) return;
data.point = point;
elements.specialOverlaySvg
.querySelectorAll(`.special-book-line[data-book-key="${hoverKey}"]`)
.forEach((line) => {
line.setAttribute("d", specialConnectorPath(data.anchor, point, data.index, cityMapConnectorPathOptions(point, data.stageRect)));
});
}
function specialBookCategory(book, special) {
return book?.["特辑分类"] || book?.category || special?.specialName || "城市书单";
}
function specialBookCategoryEn(category) {
return SPECIAL_CATEGORY_EN.get(category) || category || "City Books";
}
function specialBookIssueZh(special, category) {
return [special?.city?.cityZh || special?.city?.cityEn, category].filter(Boolean).join(" · ");
}
function specialBookIssueEn(special, category) {
return [special?.city?.cityEn || special?.city?.cityZh, specialBookCategoryEn(category)].filter(Boolean).join(" · ");
}
function createSpecialEntryMark(specialId) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("special-capsule__mark");
svg.setAttribute("viewBox", "0 0 18 18");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
const maskId = `special-entry-mark-cutout-${specialId}`;
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const mask = document.createElementNS("http://www.w3.org/2000/svg", "mask");
mask.setAttribute("id", maskId);
mask.setAttribute("maskUnits", "userSpaceOnUse");
const maskBase = document.createElementNS("http://www.w3.org/2000/svg", "rect");
maskBase.setAttribute("width", "18");
maskBase.setAttribute("height", "18");
maskBase.setAttribute("fill", "#fff");
const maskPin = document.createElementNS("http://www.w3.org/2000/svg", "path");
maskPin.setAttribute("d", "M9 3.3c-1.55 0-2.75 1.16-2.75 2.7 0 1.95 2.75 4.7 2.75 4.7s2.75-2.75 2.75-4.7c0-1.54-1.2-2.7-2.75-2.7Z");
maskPin.setAttribute("fill", "#000");
maskPin.setAttribute("stroke", "#000");
maskPin.setAttribute("stroke-width", "2.2");
maskPin.setAttribute("stroke-linejoin", "round");
mask.append(maskBase, maskPin);
defs.append(mask);
const bookPath = "M3.4 7.1c1.7-.8 3.5-.7 5.6.8 2.1-1.5 3.9-1.6 5.6-.8v5.2c-1.8-.7-3.6-.5-5.6.8-2-1.3-3.8-1.5-5.6-.8Z";
const bookGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
bookGroup.classList.add("special-capsule__mark-book-group");
bookGroup.setAttribute("mask", `url(#${maskId})`);
const bookDepth = document.createElementNS("http://www.w3.org/2000/svg", "path");
bookDepth.classList.add("special-capsule__mark-book-depth");
bookDepth.setAttribute("d", "M3.45 14.25c1.9-.58 3.72-.22 5.55.88 1.83-1.1 3.65-1.46 5.55-.88");
const book = document.createElementNS("http://www.w3.org/2000/svg", "path");
book.classList.add("special-capsule__mark-book");
book.setAttribute("d", bookPath);
const spine = document.createElementNS("http://www.w3.org/2000/svg", "path");
spine.classList.add("special-capsule__mark-spine");
spine.setAttribute("d", "M9 7.9v5.2");
bookGroup.append(bookDepth, book, spine);
const pin = document.createElementNS("http://www.w3.org/2000/svg", "path");
pin.classList.add("special-capsule__mark-pin");
pin.setAttribute("d", "M9 3.3c-1.55 0-2.75 1.16-2.75 2.7 0 1.95 2.75 4.7 2.75 4.7s2.75-2.75 2.75-4.7c0-1.54-1.2-2.7-2.75-2.7Z");
svg.append(defs, bookGroup, pin);
return svg;
}
function specialBookKey(scope, index) {
return `${scope}--book-${index}`;
}
function specialBookDraftKey(scope, bookId) {
return `${scope}--${bookId || "book"}`;
}
function setSpecialBookHover(hoverKey, hovered) {
elements.specialBookLayer
.querySelectorAll(`[data-book-key="${hoverKey}"]`)
.forEach((element) => element.classList.toggle("is-hovered", hovered));
elements.specialOverlaySvg
.querySelectorAll(`[data-book-key="${hoverKey}"]`)
.forEach((element) => element.classList.toggle("is-hovered", hovered));
}
function fallbackMark(book, special) {
if (book.isPlaceholder) return "···";
return String(book.titleZh || special.specialName || "BOOK").slice(0, 2).toUpperCase();
}
function curatingPlaceholderBook(special) {
return {
bookId: `${special.id}--curating`,
titleZh: "Curating",
category: special.city.cityZh || special.city.cityEn || special.specialName,
isPlaceholder: true,
};
}
async function fetchJson(url) {
const separator = url.includes("?") ? "&" : "?";
const response = await fetch(`${url}${separator}v=${DATA_VERSION}`);
if (!response.ok) throw new Error(`Failed to load ${url}: ${response.status}`);
return response.json();
}
async function loadSpecialBoundaries(specials) {
const entries = await Promise.all(specials.map(async (special) => {
try {
const [boundary, outline, visual] = await Promise.all([
fetchJson(`public/maps/city-specials/${special.cityId}.geojson`),
fetchJson(`public/maps/city-specials/${special.cityId}-outline.geojson`).catch(() => null),
fetchJson(`public/maps/city-specials/${special.cityId}-visual.geojson`).catch(() => null),
]);
return [special.cityId, prepareSpecialBoundary(special, boundary, outline, visual)];
} catch {
return [special.cityId, { features: [], anchors: [] }];
}
}));
return new Map(entries);
}
function ensureSpecialDataLoaded(specialOrSpecials = mapData.specials) {
const specials = Array.isArray(specialOrSpecials) ? specialOrSpecials : [specialOrSpecials];
const validSpecials = specials.filter(Boolean);
if (validSpecials.length === mapData.specials.length) {
if (!specialDataLoadPromise) {
specialDataLoadPromise = Promise.all([
loadSpecialBoundaries(mapData.specials),
loadSpecialContexts(mapData.specials.filter((special) => special.cityMap || special.useLocalContext)),
]).then(([boundaries, contexts]) => {
mapData.specialBoundaries = boundaries;
mapData.specialContexts = contexts;
return { boundaries, contexts };
});
}
return specialDataLoadPromise;
}
return Promise.all(validSpecials.map(loadSingleSpecialData));
}
function loadSingleSpecialData(special) {
const key = special.cityId || special.id;
if (!key) return Promise.resolve(null);
if (!specialDataLoadPromises.has(key)) {
specialDataLoadPromises.set(key, Promise.all([
loadSpecialBoundaries([special]),
special.cityMap || special.useLocalContext ? loadSpecialContexts([special]) : Promise.resolve(new Map()),
]).then(([boundaries, contexts]) => {
for (const [cityId, boundary] of boundaries) mapData.specialBoundaries.set(cityId, boundary);
for (const [cityId, context] of contexts) mapData.specialContexts.set(cityId, context);
return {
boundary: mapData.specialBoundaries.get(key),
context: mapData.specialContexts.get(key),
};
}));
}
return specialDataLoadPromises.get(key);
}
async function loadSpecialContexts(specials) {
const entries = await Promise.all(specials.map(async (special) => {
try {
const context = await fetchJson(`public/maps/city-specials/${special.cityId}-context.geojson`);
return [special.cityId, {
features: prepareContextFeatures(context),
landMaskRings: prepareContextLandMaskRings(context),
}];
} catch {
return [special.cityId, { features: [], landMaskRings: [] }];
}
}));
return new Map(entries);
}
function prepareSpecialBoundary(special, boundary, outline = null, visual = null) {
const displayBoundary = outline?.features?.length ? outline : boundary;
const features = prepareWorldFeatures(boundary);
const outlineFeatures = prepareWorldFeatures(displayBoundary);
const visualFeatures = visual?.features?.length ? prepareWorldFeatures(visual) : [];
const landMaskRings = visual?.features?.length ? prepareCityLandMaskRings(visual) : [];
const displayFeature = displayBoundary.features?.[0];
const code = boundary.properties?.code || displayFeature?.properties?.code || "";
const anchors = (boundary.features || [])
.map((feature) => feature.properties?.center)
.filter((point) => {
return Array.isArray(point)
&& Number.isFinite(Number(point[0]))
&& Number.isFinite(Number(point[1]));
})
.map((point) => [Number(point[0]), Number(point[1])]);
if (!anchors.length) {
const center = displayBoundary.features?.[0]?.properties?.center || displayBoundary.properties?.center;
if (Array.isArray(center) && Number.isFinite(Number(center[0])) && Number.isFinite(Number(center[1]))) {
anchors.push([Number(center[0]), Number(center[1])]);
}
}
return { features, outlineFeatures, visualFeatures, landMaskRings, anchors, code: String(code) };
}
function prepareContextFeatures(collection) {
return (collection.features || [])
.filter((feature) => ["LineString", "MultiLineString", "Polygon", "MultiPolygon"].includes(feature?.geometry?.type))
.map((feature) => {
const flatCoordinates = flattenCoordinates(feature.geometry.coordinates);
return {
feature,
flatCoordinates,
centroid: calculateCentroid(flatCoordinates),
};
});
}
function prepareContextLandMaskRings(collection) {
return prepareCityLandMaskRings({
features: (collection.features || []).filter((feature) => {
return feature.properties?.kind === "land" && ["Polygon", "MultiPolygon"].includes(feature?.geometry?.type);
}),
});
}
function featureIsoA3(feature) {
return feature.properties?.ISO_A3 || feature.properties?.ADM0_A3 || feature.properties?.ADM0_A3_US || "";
}
function countryLabelIso(feature) {
const props = feature.properties || {};
const iso = props.ISO_A3 && props.ISO_A3 !== "-99" ? props.ISO_A3 : props.ADM0_A3;
return iso || props.ADM0_A3_US || "";
}
function countryLabelEntry(worldFeature, projection) {
const feature = worldFeature.feature;
const props = feature.properties || {};
const iso = countryLabelIso(feature);
if (!shouldRenderCountryLabel(props, iso)) return null;
const point = countryLabelPoint(worldFeature);
const [x, y, visible] = projectPoint(point, projection);
if (!visible) return null;
if (!countryLabelWithinSafeGlobeArea({ x, y }, projection)) return null;
return {
x,
y,
name: COUNTRY_LABEL_OVERRIDES.get(iso) || props.NAME_EN || props.NAME || props.ADMIN,
rank: clamp(Number(props.LABELRANK || 5), 1, 6),
};
}
function shouldRenderCountryLabel(props, iso) {
if (!iso || COUNTRY_LABEL_EXCLUDED_ISO.has(iso)) return false;
if (String(props.TYPE || "").toLowerCase().includes("dependency")) return false;
if (props.SOVEREIGNT && props.ADMIN && props.SOVEREIGNT !== props.ADMIN) return false;
const rank = Number(props.LABELRANK || 99);
const maxRank = state.zoom >= COUNTRY_LABEL_POLICY.detailZoom
? COUNTRY_LABEL_POLICY.detailMaxRank
: COUNTRY_LABEL_POLICY.overviewMaxRank;
if (!Number.isFinite(rank) || rank > maxRank) return false;
if (isMicroCountry(props) && state.zoom < COUNTRY_LABEL_POLICY.microMinZoom) return false;
if (isTinyCountry(props) && state.zoom < COUNTRY_LABEL_POLICY.tinyMinZoom) return false;
return true;
}
function countryLabelWithinSafeGlobeArea(point, projection) {
const dx = point.x - projection.cx;
const dy = point.y - projection.cy;
return Math.hypot(dx, dy) <= projection.radius * COUNTRY_LABEL_EDGE_RATIO;
}
function isTinyCountry(props) {
return Number(props.LABELRANK || 99) >= 6 || Number(props.TINY || -99) >= 5;
}
function isMicroCountry(props) {
return Number(props.LABELRANK || 99) >= 6 &&
(Number(props.TINY || -99) >= 5 || Number(props.POP_RANK || 99) <= 7);
}
function countryLabelPoint(worldFeature) {
const props = worldFeature.feature.properties || {};
const lon = Number(props.LABEL_X);
const lat = Number(props.LABEL_Y);
if (Number.isFinite(lon) && Number.isFinite(lat)) {
const labelPoint = [lon, lat];
if (pointInFeature(labelPoint, worldFeature.feature)) return labelPoint;
}
if (pointInFeature(worldFeature.centroid, worldFeature.feature)) {
return worldFeature.centroid;
}
const interiorPoint = largestPolygonInteriorPoint(worldFeature.feature);
if (interiorPoint) return interiorPoint;
if (Number.isFinite(lon) && Number.isFinite(lat)) return [lon, lat];
return worldFeature.centroid;
}
function pointInFeature(point, feature) {
const geometry = feature?.geometry;
if (!geometry || !Array.isArray(point)) return false;
if (geometry.type === "Polygon") return pointInPolygon(point, geometry.coordinates);
if (geometry.type === "MultiPolygon") {
return geometry.coordinates.some((polygon) => pointInPolygon(point, polygon));
}
return false;
}
function pointInPolygon(point, rings) {
if (!Array.isArray(rings) || !rings.length) return false;
if (!pointInRing(point, rings[0])) return false;
return !rings.slice(1).some((ring) => pointInRing(point, ring));
}
function pointInRing([x, y], ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
const intersects = ((yi > y) !== (yj > y)) &&
(x < ((xj - xi) * (y - yi)) / ((yj - yi) || Number.EPSILON) + xi);
if (intersects) inside = !inside;
}
return inside;
}
function largestPolygonInteriorPoint(feature) {
const geometry = feature?.geometry;
const polygons = geometry?.type === "Polygon"
? [geometry.coordinates]
: geometry?.type === "MultiPolygon"
? geometry.coordinates
: [];
const rings = polygons
.map((polygon) => polygon?.[0])
.filter(Boolean)
.sort((a, b) => Math.abs(ringArea(b)) - Math.abs(ringArea(a)));
for (const ring of rings) {
const candidate = ringCentroid(ring);
if (candidate && pointInFeature(candidate, feature)) return candidate;
const fallback = ringBBoxCenter(ring);
if (fallback && pointInFeature(fallback, feature)) return fallback;
}
return null;
}
function ringArea(ring) {
let area = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
area += (ring[j][0] * ring[i][1]) - (ring[i][0] * ring[j][1]);
}
return area / 2;
}
function ringCentroid(ring) {
const area = ringArea(ring);
if (!Number.isFinite(area) || Math.abs(area) < 1e-8) return null;
let x = 0;
let y = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const cross = (ring[j][0] * ring[i][1]) - (ring[i][0] * ring[j][1]);
x += (ring[j][0] + ring[i][0]) * cross;
y += (ring[j][1] + ring[i][1]) * cross;
}
return [x / (6 * area), y / (6 * area)];
}
function ringBBoxCenter(ring) {
const xs = ring.map(([x]) => x);
const ys = ring.map(([, y]) => y);
return [
(Math.min(...xs) + Math.max(...xs)) / 2,
(Math.min(...ys) + Math.max(...ys)) / 2,
];
}
function isSpecialMapFeature(feature, special, boundary, useRegionalChina) {
if (!special) return false;
if (useRegionalChina) {
const featureCode = feature.properties?.code;
if (boundary?.code && String(featureCode) === boundary.code) return true;
const featureName = feature.properties?.fullname || feature.properties?.name;
return Boolean(featureName && featureName === special.city.cityZh);
}
return featureIsoA3(feature) === special.city?.isoA3;
}
function normalizeCities(cities = []) {
return cities.filter((city) => {
const [lon, lat] = city.coordinates || [city.lon, city.lat];
return city.mapStatus !== "hidden" && Number.isFinite(Number(lon)) && Number.isFinite(Number(lat));
});
}
function normalizeBookRecords(books = []) {
return books.map((book) => ({
...book,
bookId: book.bookId || book.id,
coverUrl: bestCoverUrl(book),
}));
}
function bookIsSelected(book) {
return book?.selectionStatus === "selected" || book?.isSelected === true;
}
function buildSpecials() {
const pointsByCity = new Map(mapData.cities.map((city) => [city.cityId || city.id, city]));
const catalogByCity = new Map(mapData.cityCatalog.map((city) => [city.id, city]));
const booksBySpecial = groupBooksBySpecial(mapData.books);
const selectedBooksByCity = groupSelectedBooksByCity(mapData.cities);
return SPECIAL_DEFINITIONS.map((definition) => {
const point = pointsByCity.get(definition.cityId);
const catalogCity = catalogByCity.get(definition.cityId);
const city = {
...(catalogCity || {}),
...(point || {}),
id: point?.id || definition.cityId,
cityId: definition.cityId,
};
const curatedBooks = booksBySpecial.get(definition.specialName) || [];
const fallbackBooks = selectedBooksByCity.get(definition.cityId) || [];
const books = curatedBooks.length ? curatedBooks : fallbackBooks;
return {
...definition,
city,
books,
};
}).filter((special) => cityCoordinates(special.city).every((value) => Number.isFinite(value)));
}
function groupBooksBySpecial(books) {
const groups = new Map();
for (const book of books) {
const specialName = book["特辑名称"];
if (!specialName || specialName === "否") continue;
if (!groups.has(specialName)) groups.set(specialName, []);
groups.get(specialName).push(specialBookPayload(book));
}
for (const group of groups.values()) {
group.sort((a, b) => Number(a["特辑排序"] || 999) - Number(b["特辑排序"] || 999));
}
return groups;
}
function groupSelectedBooksByCity(points) {
const groups = new Map();
for (const point of points) {
const cityId = point.cityId || point.id;
if (!cityId) continue;
groups.set(cityId, pointBooks(point).map(specialBookPayload));
}
return groups;
}
function specialBookPayload(book) {
return {
...book,
bookId: book.bookId || book.id,
coverUrl: bestCoverUrl(book),
};
}
function bestCoverUrl(book) {
return book?.localCoverUrl || book?.coverUrl || book?.remoteCoverUrl || "";
}
function prepareWorldFeatures(world) {
return (world.features || []).filter(isAreaFeature).map((feature) => {
const flatCoordinates = flattenCoordinates(feature.geometry.coordinates);
return {
feature,
pathKey: `geo-${globeFeaturePathId += 1}`,
flatCoordinates,
centroid: calculateCentroid(flatCoordinates),
};
});
}
function prepareCityLandMaskRings(collection) {
const rings = [];
for (const feature of collection.features || []) {
const geometry = feature?.geometry;
if (!geometry || !["Polygon", "MultiPolygon"].includes(geometry.type)) continue;
const polygons = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.coordinates;
for (const polygon of polygons) {
for (const ring of polygon) {
rings.push({ ring, bounds: ringGeoBounds(ring) });
}
}
}
return rings;
}
function ringGeoBounds(ring) {
let minLon = Infinity;
let maxLon = -Infinity;
let minLat = Infinity;
let maxLat = -Infinity;
for (const [lon, lat] of ring) {
minLon = Math.min(minLon, lon);
maxLon = Math.max(maxLon, lon);
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
}
return { minLon, maxLon, minLat, maxLat };
}
function isAreaFeature(feature) {
return ["Polygon", "MultiPolygon"].includes(feature?.geometry?.type);
}
function renderCities(projection, options = {}) {
const visibleCityIds = new Set();
const visibleEntries = [];
const special = activeSpecial();
const cities = special ? [special.city] : mapData.cities;
const stageProjection = projectionToStageGeometry(projection);
for (const city of cities) {
const [lon, lat] = cityCoordinates(city);
const [x, y, visible] = projectPoint([lon, lat], projection);
if (!visible) continue;
visibleCityIds.add(city.id);
const markerEntry = cityMarkerCache.get(city.id) || createCityMarker(city);
const point = svgToStageFreePoint(x, y);
updateCityMarkerBase(markerEntry, city, point);
if (markerEntry.element.parentNode !== elements.cityLabelLayer) {
elements.cityLabelLayer.append(markerEntry.element);
}
visibleEntries.push({ city, markerEntry, point });
}
for (const cityId of cityMarkerCache.keys()) {
const markerEntry = cityMarkerCache.get(cityId);
markerEntry.element.hidden = !visibleCityIds.has(cityId);
}
if (options.freezeCityLabelLayout) {
preserveCityLabelPlacements(visibleEntries, stageProjection);
} else {
resolveCityLabelCollisions(visibleEntries, stageProjection);
}
}
function createCityMarker(city) {
const element = document.createElement("div");
element.className = "city-html-marker";
element.tabIndex = 0;
element.setAttribute("role", "button");
element.setAttribute("aria-label", pointLabel(city));
const halo = document.createElement("span");
halo.className = "city-html-marker__halo";
const pointElement = document.createElement("span");
pointElement.className = "city-html-marker__point";
const label = document.createElement("span");
label.className = "city-html-marker__label";
const zh = document.createElement("span");
zh.className = "city-html-marker__zh";
zh.textContent = city.cityZh || city.cityEn || "";
const en = document.createElement("span");
en.className = "city-html-marker__en";
en.textContent = city.cityEn && city.cityEn !== city.cityZh ? city.cityEn : "";
en.hidden = !en.textContent;
label.append(zh, en);
element.append(halo, pointElement, label);
element.addEventListener("pointerenter", () => {
state.hoverPoint = city;
element.classList.add("is-hovered");
warmBookCoversForPoint(city, { priority: "high" });
});
element.addEventListener("pointerleave", () => {
if (state.hoverPoint?.id === city.id) state.hoverPoint = null;
element.classList.remove("is-hovered");
});
element.addEventListener("focus", () => {
state.hoverPoint = city;
warmBookCoversForPoint(city, { priority: "high" });
});
element.addEventListener("blur", () => {
if (state.hoverPoint?.id === city.id) state.hoverPoint = null;
});
element.addEventListener("pointerdown", () => {
warmBookCoversForPoint(city, { priority: "high" });
playUiSound("pageFlick");
});
element.addEventListener("click", () => {
openCityMarker(city, "pointer");
});
element.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
playUiSound("pageFlick");
openCityMarker(city, "keyboard");
}
});
const markerEntry = {
element,
halo,
pointElement,
label,
zh,
en,
width: CITY_LABEL_METRICS.width,
height: CITY_LABEL_METRICS.height,
zhCenterY: CITY_LABEL_METRICS.zhCenterY,
enCenterY: CITY_LABEL_METRICS.enCenterY,
labelMetricsMeasured: false,
point: { x: 0, y: 0 },
};
cityMarkerCache.set(city.id, markerEntry);
return markerEntry;
}
function openCityMarker(city, inputMethod = "pointer") {
if (state.activePoint?.id === city.id && !elements.cityTooltip.hidden) {
closeTooltip();
return;
}
state.activePoint = city;
trackEvent("city_marker_opened", cityAnalyticsProps(city, {
source_view: "global_map",
input_method: inputMethod,
}));
trackBookTicketOpened(city, "global_map", {
input_method: inputMethod,
});
syncCityMarkerActiveClasses();
syncTooltip(createProjection());
}
function updateCityMarkerBase(markerEntry, city, point) {
markerEntry.element.hidden = false;
const special = activeSpecial();
const isSpecialCenter = Boolean(special && (city.cityId || city.id) === special.cityId);
const nextClassName = `city-html-marker ${cityWeightClass(city)}${state.activePoint?.id === city.id ? " is-active" : ""}${state.hoverPoint?.id === city.id ? " is-hovered" : ""}${isSpecialCenter ? " is-special-center" : ""}`;
if (markerEntry.element.className !== nextClassName) markerEntry.element.className = nextClassName;
markerEntry.element.style.setProperty("--marker-x", `${point.x.toFixed(2)}px`);
markerEntry.element.style.setProperty("--marker-y", `${point.y.toFixed(2)}px`);
if (!markerEntry.staticMetricsApplied) {
markerEntry.element.style.setProperty("--dot-size", `${cityRadius(city) * 2}px`);
markerEntry.element.style.setProperty("--halo-size", `${cityHaloRadius(city) * 2}px`);
markerEntry.staticMetricsApplied = true;
}
markerEntry.point = point;
measureCityMarkerLabel(markerEntry);
if (!markerEntry.labelWidthApplied) {
markerEntry.element.style.setProperty("--label-width", `${markerEntry.width.toFixed(2)}px`);
markerEntry.labelWidthApplied = true;
}
}
function measureCityMarkerLabel(markerEntry) {
if (markerEntry.labelMetricsMeasured) return;
const labelRect = markerEntry.label.getBoundingClientRect();
markerEntry.width = labelRect.width || markerEntry.width;
markerEntry.height = CITY_LABEL_METRICS.height;
markerEntry.zhCenterY = CITY_LABEL_METRICS.zhCenterY;
markerEntry.enCenterY = markerEntry.en.hidden ? CITY_LABEL_METRICS.zhCenterY : CITY_LABEL_METRICS.enCenterY;
markerEntry.labelMetricsMeasured = true;
}
function resolveCityLabelCollisions(entries, projection) {
const occupiedBoxes = [];
const sortedEntries = [...entries].sort((a, b) => labelPriority(b.city) - labelPriority(a.city));
for (const entry of sortedEntries) {
const placement = chooseLabelPlacement(entry, occupiedBoxes, projection);
applyCityLabelPlacement(entry.markerEntry, placement);
if (!placement.hideLabel) {
occupiedBoxes.push(labelCollisionBox(placement.box, entry.city));
}
}
}
function preserveCityLabelPlacements(entries, projection) {
const occupiedBoxes = [];
const sortedEntries = [...entries].sort((a, b) => labelPriority(b.city) - labelPriority(a.city));
for (const entry of sortedEntries) {
if (!entry.markerEntry.labelPlacement) continue;
const labelWithinGlobe = syncCityLabelEdgeVisibility(entry.markerEntry, projection);
if (labelWithinGlobe) {
occupiedBoxes.push(labelCollisionBox(cityLabelPlacementBox(entry.markerEntry), entry.city));
}
}
for (const entry of sortedEntries) {
if (entry.markerEntry.labelPlacement) continue;
const placement = chooseLabelPlacement(entry, occupiedBoxes, projection);
applyCityLabelPlacement(entry.markerEntry, placement);
if (!placement.hideLabel) {
occupiedBoxes.push(labelCollisionBox(placement.box, entry.city));
}
}
}
function chooseLabelPlacement(entry, occupiedBoxes, projection) {
const candidates = labelPlacementCandidates(entry, projection);
const globeSafeCandidates = candidates.filter((candidate) => candidate.labelWithinGlobe);
const candidatePool = globeSafeCandidates.length
? globeSafeCandidates
: candidates.map((candidate) => ({ ...candidate, hideLabel: true }));
const nonOverlapping = candidatePool.filter((candidate) => {
return candidate.hideLabel || !occupiedBoxes.some((box) => boxesOverlap(candidate.box, box));
});
const pool = nonOverlapping.length ? nonOverlapping : candidatePool;
return [...pool].sort((a, b) => labelCandidateScore(a, occupiedBoxes) - labelCandidateScore(b, occupiedBoxes))[0];
}
function labelCandidateScore(candidate, occupiedBoxes) {
const overlapPenalty = collisionScore(candidate.box, occupiedBoxes) * 100;
const distancePenalty = Math.max(0, candidate.distance - 54) * 24;
return overlapPenalty + distancePenalty + candidate.priority * 8 + candidate.distance;
}
function labelPriority(city) {
const guidedPlacementBoost = [
"chn-nanjing",
"chn-suzhou",
"chn-shanghai",
"chn-hangzhou",
"chn-wuhan",
"chn-chongqing",
"chn-jingdezhen",
"chn-xian",
"vnm-ho-chi-minh-city",
"jpn-tokyo",
"chn-qingdao",
"chn-taipei",
"chn-xiamen",
].includes(city.cityId || city.id) ? 220 : 0;
const weight = Number(city.displayWeight || 0);
const sortBias = 100 - Number(city.sortOrder || 0);
return guidedPlacementBoost + weight + sortBias / 100;
}
function labelCollisionBox(box, city) {
const weight = Number(city.displayWeight || 0);
const shrink = weight >= 90 ? 1 : 7;
return {
left: box.left + shrink,
right: box.right - shrink,
top: box.top + shrink * 0.55,
bottom: box.bottom - shrink * 0.55,
};
}
function labelPlacementCandidates(entry, projection) {
const { markerEntry } = entry;
const width = markerEntry.width || 62;
const height = markerEntry.height || 27;
const horizontalGap = 9;
const zhCenterY = markerEntry.zhCenterY || 7;
const enCenterY = markerEntry.en?.hidden ? zhCenterY : markerEntry.enCenterY || 19;
const englishAlignedTop = -enCenterY;
const chineseAlignedTop = -zhCenterY;
const candidates = labelPlacementOrder(entry).map((placement, index) => {
const alignTo = placement.alignTo || "en";
const baseY = alignTo === "zh" ? chineseAlignedTop : englishAlignedTop;
if (placement.x != null && placement.y != null) {
return {
key: `${placement.key}-${index}`,
x: typeof placement.x === "function" ? placement.x(width, horizontalGap) : placement.x,
y: baseY + (typeof placement.y === "function" ? placement.y(height) : placement.y),
align: placement.align || "left",
priority: index,
};
}
const isRight = placement.side === "right";
const x = isRight ? horizontalGap : -horizontalGap - width;
const align = isRight ? "left" : "right";
return {
key: `${placement.side}-${placement.alignTo}-${placement.offset}`,
x,
y: baseY + placement.offset,
align,
priority: index,
};
});
return candidates.map((candidate) => {
const box = {
left: markerEntry.point.x + candidate.x,
right: markerEntry.point.x + candidate.x + width,
top: markerEntry.point.y + candidate.y,
bottom: markerEntry.point.y + candidate.y + height,
};
return {
...candidate,
distance: Math.hypot(candidate.x + width / 2, candidate.y + height / 2),
box,
labelWithinGlobe: cityLabelBoxWithinGlobe(box, projection),
};
});
}
function cityLabelBoxWithinGlobe(box, projection) {
if (!projection) return true;
const maxDistance = projection.radius - CITY_LABEL_EDGE_PADDING;
return [
{ x: box.left, y: box.top },
{ x: box.right, y: box.top },
{ x: box.left, y: box.bottom },
{ x: box.right, y: box.bottom },
].every((point) => Math.hypot(point.x - projection.cx, point.y - projection.cy) <= maxDistance);
}
function labelPlacementOrder(entry) {
const cityId = entry.city.cityId || entry.city.id || "";
const fixedRightUpperPlacements = [
{ side: "right", alignTo: "en", offset: 0 },
];
const fixedLeftUpperPlacements = [
{ side: "left", alignTo: "en", offset: 0 },
];
const fixedRightLowerPlacements = [
{ side: "right", alignTo: "zh", offset: 8 },
];
const fixedLeftLowerPlacements = [
{ side: "left", alignTo: "zh", offset: 8 },
];
const rightSidePlacements = [
{ side: "right", alignTo: "en", offset: 0 },
{ side: "right", alignTo: "en", offset: -8 },
{ side: "right", alignTo: "en", offset: 8 },
{ side: "right", alignTo: "en", offset: -16 },
{ side: "right", alignTo: "en", offset: 16 },
{ side: "right", alignTo: "zh", offset: 8 },
];
const leftSidePlacements = [
{ side: "left", alignTo: "en", offset: 0 },
{ side: "left", alignTo: "en", offset: -8 },
{ side: "left", alignTo: "en", offset: 8 },
{ side: "left", alignTo: "en", offset: -16 },
{ side: "left", alignTo: "en", offset: 16 },
{ side: "left", alignTo: "zh", offset: 8 },
];
const wideAvoidancePlacements = [
{ side: "right", alignTo: "en", offset: -22 },
{ side: "right", alignTo: "en", offset: 12 },
{ side: "right", alignTo: "en", offset: 22 },
{ side: "left", alignTo: "en", offset: -22 },
{ side: "left", alignTo: "en", offset: 22 },
];
const deltaPlacements = {
"aus-canberra": [
...fixedRightLowerPlacements,
],
"zaf-johannesburg": [
...fixedRightLowerPlacements,
],
"chn-beijing": [
...fixedRightUpperPlacements,
],
"chn-nanjing": [
{ side: "right", alignTo: "en", offset: 0 },
{ key: "nanjing-top-right", alignTo: "en", x: 9, y: -8, align: "left" },
...leftSidePlacements,
],
"chn-suzhou": [
{ key: "suzhou-left", alignTo: "en", x: (width, gap) => -width - gap - 2, y: 0, align: "right" },
{ key: "suzhou-left-up", alignTo: "en", x: (width, gap) => -width - gap - 2, y: -8, align: "right" },
{ key: "suzhou-left-low", alignTo: "zh", x: (width, gap) => -width - gap - 2, y: 5, align: "right" },
],
"chn-shanghai": [
...fixedRightUpperPlacements,
],
"chn-hangzhou": [
{ side: "right", alignTo: "zh", offset: 8 },
],
"chn-haikou": [
{ side: "left", alignTo: "en", offset: 8 },
],
"chn-tianjin": [
{ side: "left", alignTo: "en", offset: 8 },
],
"chn-jinan": [
...fixedRightUpperPlacements,
],
"chn-jingdezhen": [
{ key: "jingdezhen-right-low", alignTo: "zh", x: 9, y: 5, align: "left" },
],
"chn-chengdu": [
...fixedRightUpperPlacements,
],
"vnm-hanoi": [
...fixedRightUpperPlacements,
],
"mmr-yangon": [
...fixedRightUpperPlacements,
],
"mys-george-town": [
...fixedLeftUpperPlacements,
],
"mys-kuala-lumpur": [
...fixedLeftLowerPlacements,
],
"mys-johor-bahru": [
...fixedLeftUpperPlacements,
],
"idn-medan": [
...fixedLeftLowerPlacements,
],
"kor-seoul": [
...fixedRightLowerPlacements,
],
"swe-stockholm": [
...fixedRightUpperPlacements,
],
"fra-paris": [
...fixedRightLowerPlacements,
],
"fra-marseille": [
...fixedRightUpperPlacements,
],
"cze-prague": [
...fixedRightUpperPlacements,
],
"esp-barcelona": [
...fixedLeftLowerPlacements,
],
"mar-marrakech": [
...fixedRightLowerPlacements,
],
"mar-casablanca": [
...fixedRightLowerPlacements,
],
"egy-cairo": [
...fixedLeftUpperPlacements,
],
"jor-amman": [
...fixedRightLowerPlacements,
],
"isr-tel-aviv": [
...fixedLeftUpperPlacements,
],
"isr-jerusalem": [
...fixedLeftLowerPlacements,
],
"lbn-beirut": [
...fixedRightUpperPlacements,
],
"syr-damascus": [
...fixedRightLowerPlacements,
],
"ita-rome": [
...fixedRightLowerPlacements,
],
"ita-florence": [
...fixedRightLowerPlacements,
],
"bih-sarajevo": [
...fixedRightLowerPlacements,
],
"srb-belgrade": [
...fixedRightUpperPlacements,
],
"arg-buenos-aires": [
...fixedLeftLowerPlacements,
],
"usa-cincinnati": [
...fixedLeftLowerPlacements,
],
"usa-pittsburgh": [
...fixedLeftUpperPlacements,
],
"usa-washington-d-c": [
...fixedLeftLowerPlacements,
],
"usa-baltimore": [
...fixedRightLowerPlacements,
],
"chn-guangzhou": [
{ side: "left", alignTo: "en", offset: 0 },
],
"chn-shenzhen": [
...fixedRightUpperPlacements,
],
"chn-hong-kong": [
{ side: "right", alignTo: "zh", offset: 8 },
],
"chn-macau": [
{ side: "left", alignTo: "zh", offset: 8 },
],
"chn-wuhan": [
...fixedRightUpperPlacements,
],
"chn-chongqing": [
{ key: "chongqing-right-low", alignTo: "zh", x: 9, y: 5, align: "left" },
],
"chn-xian": [
{ key: "xian-right-up", alignTo: "en", x: 8, y: -2, align: "left" },
],
"vnm-ho-chi-minh-city": [
{ key: "ho-chi-minh-right-up", alignTo: "en", x: 8, y: -4, align: "left" },
],
"jpn-tokyo": [
...fixedRightUpperPlacements,
],
"jpn-kyoto": [
...fixedLeftUpperPlacements,
],
"jpn-osaka": [
...fixedLeftLowerPlacements,
],
"jpn-kamakura": [
...fixedLeftLowerPlacements,
],
"chn-qingdao": [
...fixedRightUpperPlacements,
],
"chn-taipei": [
...fixedRightUpperPlacements,
],
"chn-xiamen": [
...fixedRightLowerPlacements,
],
"chn-quanzhou": [
...fixedLeftUpperPlacements,
],
"nld-amsterdam": [
...fixedRightUpperPlacements,
],
"bel-antwerp": [
{ side: "right", alignTo: "zh", offset: 8 },
{ side: "right", alignTo: "zh", offset: 12 },
{ side: "right", alignTo: "en", offset: 16 },
],
"vat-vatican-city": [
...fixedLeftUpperPlacements,
],
};
if (deltaPlacements[cityId]) {
return deltaPlacements[cityId];
}
return [...rightSidePlacements, ...leftSidePlacements, ...wideAvoidancePlacements];
}
function applyCityLabelPlacement(markerEntry, placement) {
markerEntry.labelPlacement = placement;
markerEntry.labelPlacementKey = placement.key;
markerEntry.element.classList.toggle("is-label-outside-globe", Boolean(placement.hideLabel));
markerEntry.element.style.setProperty("--label-x", `${placement.x.toFixed(2)}px`);
markerEntry.element.style.setProperty("--label-y", `${placement.y.toFixed(2)}px`);
markerEntry.element.style.setProperty("--label-align", placement.align);
}
function syncCityLabelEdgeVisibility(markerEntry, projection) {
const placement = markerEntry.labelPlacement;
if (!placement) return false;
const box = cityLabelPlacementBox(markerEntry);
const labelWithinGlobe = cityLabelBoxWithinGlobe(box, projection);
markerEntry.element.classList.toggle("is-label-outside-globe", !labelWithinGlobe);
return labelWithinGlobe;
}
function cityLabelPlacementBox(markerEntry) {
const placement = markerEntry.labelPlacement;
const width = markerEntry.width || 62;
const height = markerEntry.height || CITY_LABEL_METRICS.height;
return {
left: markerEntry.point.x + placement.x,
right: markerEntry.point.x + placement.x + width,
top: markerEntry.point.y + placement.y,
bottom: markerEntry.point.y + placement.y + height,
};
}
function collisionScore(box, occupiedBoxes) {
return occupiedBoxes.reduce((score, occupiedBox) => score + overlapArea(box, occupiedBox), 0);
}
function overlapArea(a, b) {
const xOverlap = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
const yOverlap = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
return xOverlap * yOverlap;
}
function boxesOverlap(a, b) {
const padding = 3;
return !(
a.right + padding < b.left ||
a.left > b.right + padding ||
a.bottom + padding < b.top ||
a.top > b.bottom + padding
);
}
function syncTooltip(projection, options = {}) {
const city = state.activePoint;
if (!city) {
elements.cityTooltip.hidden = true;
return;
}
if (state.bookCardMode === "detail") {
if (!options.skipTooltipContent) updateTooltipContent(city);
placeDetailTooltip();
return;
}
if (city.tooltipAnchor) {
if (!options.skipTooltipContent) updateTooltipContent(city);
placeTooltipAtAnchor(city.tooltipAnchor, city, { avoidMarker: false });
return;
}
const [lon, lat] = cityCoordinates(city);
const [x, y, visible] = projectPoint([lon, lat], projection);
if (!visible) {
state.tooltipPoint = null;
elements.cityTooltip.hidden = true;
return;
}
const point = svgToStageFreePoint(x, y);
if (!options.skipTooltipContent) updateTooltipContent(city);
placeTooltipAtAnchor(point, city, { avoidMarker: true });
}
function activeSpecial() {
return mapData.specials.find((special) => special.id === state.activeSpecialId) || null;
}
function pointUsesSpecialBookmarkLayer(point) {
const specialId = point?.specialContext?.id;
if (!specialId) return false;
const special = mapData.specials.find((item) => item.id === specialId) || activeSpecial();
return Boolean(special?.cityMap);
}
function enterSpecialView(specialId) {
const special = mapData.specials.find((item) => item.id === specialId);
if (!special) return;
trackEvent("city_opened", cityAnalyticsProps(special.city, {
source_view: "special_entry",
special_id: special.id,
special_name: special.specialName,
}));
const cityOceanTransition = special.cityMap ? captureCityOceanTransitionFromGlobe() : null;
if (!state.activeSpecialId) {
state.returnViewBeforeSpecial = captureCurrentGlobeView();
}
state.activeSpecialId = special.id;
state.activePoint = null;
state.tooltipPoint = null;
state.bookCardMode = "ticket";
state.detailOrigin = null;
state.detailReturnTarget = null;
state.activeSpecialStackKey = null;
state.specialStackIndexes.clear();
state.specialStackRenderData.clear();
elements.cityTooltip.hidden = true;
closeSharePreview();
const targetCenter = specialViewCenter(special);
if (special.cityMap) {
warmCitySpecialMapAssets(special, "high");
applyCityOceanTransitionFromGlobe(cityOceanTransition);
beginCitySpecialAssetLoad(special);
playUiSound("cityMapLoading");
if (state.viewAnimationFrame) cancelAnimationFrame(state.viewAnimationFrame);
state.viewAnimationFrame = null;
state.center = [normalizeLongitude(targetCenter[0]), clamp(targetCenter[1], -72, 72)];
state.zoom = special.focusZoom;
render();
startCityOceanTransition(special);
ensureSpecialDataLoaded(special).then(() => {
if (state.activeSpecialId === special.id) render();
}).catch(() => {});
return;
}
animateView({
center: targetCenter,
zoom: special.focusZoom,
});
ensureSpecialDataLoaded(special).then(() => {
if (state.activeSpecialId === special.id) render();
}).catch(() => {});
}
function specialViewCenter(special) {
const [lon, lat] = cityCoordinates(special.city);
const [offsetLon = 0, offsetLat = 0] = Array.isArray(special.viewCenterOffset)
? special.viewCenterOffset
: [];
return [
lon + (Number.isFinite(Number(offsetLon)) ? Number(offsetLon) : 0),
lat + (Number.isFinite(Number(offsetLat)) ? Number(offsetLat) : 0),
];
}
function initialGlobeView() {
return {
center: [...INITIAL_VIEW.center],
zoom: INITIAL_VIEW.zoom,
};
}
function captureCurrentGlobeView() {
return {
center: [...state.center],
zoom: state.zoom,
};
}
function exitSpecialView(options = {}) {
if (options.playSound && state.activeSpecialId) playUiSound("paperClose");
const returnView = state.returnViewBeforeSpecial ?? initialGlobeView();
state.activeSpecialId = null;
state.returnViewBeforeSpecial = null;
state.activePoint = null;
state.tooltipPoint = null;
state.bookCardMode = "ticket";
state.detailOrigin = null;
state.detailReturnTarget = null;
state.activeSpecialStackKey = null;
state.specialStackIndexes.clear();
state.specialStackRenderData.clear();
resetCitySpecialAssetLoad();
elements.cityTooltip.hidden = true;
closeSharePreview();
animateView({
center: [...returnView.center],
zoom: returnView.zoom,
});
}
function openSpecialBook(special, book, point, index = 0) {
state.bookCardMode = "detail";
state.detailOrigin = null;
state.detailReturnTarget = null;
closeSharePreview();
const category = specialBookCategory(book, special);
const entries = specialSharePreviewEntries(special);
const fallbackBook = specialBookPayload(book);
const books = entries.length ? entries.map((entry) => entry.book) : [fallbackBook];
const clickedBookId = bookIdentity(fallbackBook);
const orderedIndex = entries.findIndex((entry) => {
const entryBookId = bookIdentity(entry.book);
if (clickedBookId && entryBookId) return entryBookId === clickedBookId;
return entry.sourceIndex === index;
});
const bookIndex = clamp(orderedIndex >= 0 ? orderedIndex : index, 0, Math.max(books.length - 1, 0));
const pointId = `${special.id}--special-books`;
state.activePoint = {
...special.city,
id: pointId,
books,
specialContext: {
id: special.id,
category,
categoryEn: specialBookCategoryEn(category),
},
tooltipAnchor: {
x: point.x,
y: point.y,
},
};
state.activeBookIndexes.set(pointId, bookIndex);
playUiSound("bookmarkOpen");
syncTooltip(createProjection());
}
function syncSpecialBackButton() {
const inSpecialView = Boolean(state.activeSpecialId);
const cityMap = Boolean(activeSpecial()?.cityMap);
document.documentElement.classList.toggle("is-special-view", inSpecialView);
document.documentElement.classList.toggle("is-special-city-map", cityMap);
elements.specialBackButton.hidden = false;
elements.specialBackButton.tabIndex = inSpecialView ? 0 : -1;
elements.specialBackButton.setAttribute("aria-hidden", String(!inSpecialView));
elements.themeToggle.tabIndex = inSpecialView ? -1 : 0;
elements.themeToggle.setAttribute("aria-hidden", String(inSpecialView));
if (elements.bookshelfToggle) {
elements.bookshelfToggle.tabIndex = inSpecialView || state.bookshelfOpen ? -1 : 0;
elements.bookshelfToggle.setAttribute("aria-hidden", String(inSpecialView || state.bookshelfOpen));
}
}
function updateTooltipContent(city) {
const books = pointBooks(city);
const bookIndex = activeBookIndex(city);
const book = books[bookIndex] || city;
const isSpecialBookDetail = Boolean(city?.specialContext);
const cardCity = currentBookCardCity(city, book);
warmBookCoversForPoint(city, { priority: "high" });
applyBookCardModeClass();
if (state.bookCardMode !== "detail") renderBookTicketStack(cardCity, books, bookIndex);
elements.cityTooltip.querySelectorAll(".book-card__explore").forEach((explore) => {
explore.hidden = state.bookCardMode !== "detail";
explore.classList.toggle("is-special-book-explore", isSpecialBookDetail);
explore.querySelectorAll(".book-card__city-page").forEach((cityButton) => {
cityButton.hidden = isSpecialBookDetail;
cityButton.tabIndex = isSpecialBookDetail ? -1 : 0;
cityButton.setAttribute("aria-hidden", String(isSpecialBookDetail));
});
});
elements.cityTooltip.querySelectorAll(".book-card__pager").forEach((pager) => {
pager.hidden = books.length <= 1;
});
elements.cityTooltip.querySelectorAll(".book-card__counter").forEach((counter) => {
counter.textContent = `${bookIndex + 1} / ${books.length}`;
});
const detailView = elements.cityTooltip.querySelector(".book-card__detail-view");
if (detailView) fillBookCard(detailView, cardCity, book);
updateSaveButtons(book);
warmShareHalftoneQueue();
}
function currentBookCardCity(city, book) {
if (!city?.specialContext) return city;
const special = mapData.specials.find((item) => item.id === city.specialContext.id) || activeSpecial();
const category = specialBookCategory(book, special);
return {
...city,
specialContext: {
...city.specialContext,
category,
categoryEn: specialBookCategoryEn(category),
},
};
}
function renderBookTicketStack(city, books, activeIndex) {
const ticketView = elements.cityTooltip.querySelector(".book-card__ticket-view");
if (!ticketView) return;
const template = bookTicketTemplate(ticketView);
if (!template) return;
const closeButton = ticketView.querySelector(".book-card__close--float");
if (closeButton) ticketView.append(closeButton);
const previousRects = captureTicketStackRects(ticketView);
const shouldAnimateStack = previousRects.size > 0;
ticketView.classList.toggle("is-prepping-stack-motion", shouldAnimateStack);
ticketView.querySelectorAll(".book-ticket-shell, .book-ticket").forEach((ticket) => ticket.remove());
const visibleIndexes = ticketStackVisibleIndexes(books.length, activeIndex);
visibleIndexes.forEach((rawIndex, index) => {
warmBookCover(books[rawIndex], index === 0 ? "high" : "low");
});
const fragment = document.createDocumentFragment();
visibleIndexes.forEach((rawIndex, index) => {
const book = books[rawIndex];
const shell = document.createElement("div");
shell.className = "book-ticket-shell";
shell.dataset.bookIndex = String(rawIndex);
shell.classList.add("is-entering");
shell.style.setProperty("--ticket-enter-delay", `${index * 54}ms`);
const ticket = template.cloneNode(true);
ticket.dataset.bookIndex = String(rawIndex);
ticket.dataset.bookId = book.bookId || book.id || "";
ticket.tabIndex = 0;
ticket.setAttribute("role", "button");
ticket.setAttribute("aria-label", `${fullBookTitle(book, city) || "书票"},第 ${rawIndex + 1} 本`);
ticket.classList.toggle("is-selected", rawIndex === activeIndex);
shell.classList.toggle("is-selected", rawIndex === activeIndex);
applyBookTicketStackStyle(shell, index, visibleIndexes.length);
shell.append(ticket);
fillBookCard(ticket, city, book);
updateBookTicketSaveButton(ticket, book);
if (index === 0 && closeButton) ticket.append(closeButton);
fragment.append(shell);
});
ticketView.append(fragment);
elements.cityTooltip.classList.toggle("has-book-stack", state.bookCardMode === "ticket" && books.length > 1);
elements.cityTooltip.style.setProperty("--book-stack-count", String(visibleIndexes.length));
elements.cityTooltip.style.setProperty("--book-stack-width", `${222 + (visibleIndexes.length - 1) * TICKET_STACK_PEEK}px`);
scheduleTicketCutPaths(ticketView);
animateBookTicketStack(ticketView, previousRects);
}
function ticketStackVisibleIndexes(total, activeIndex) {
const safeActiveIndex = Math.max(0, Math.min(total - 1, activeIndex));
const ordered = Array.from({ length: total }, (_, index) => (safeActiveIndex + index) % total);
return ordered.slice(0, TICKET_STACK_MAX_VISIBLE);
}
function bookCoverPreloadUrl(book) {
const coverUrl = book?.coverUrl || "";
if (!coverUrl) return "";
try {
return new URL(coverUrl, window.location.href).href;
} catch {
return String(coverUrl);
}
}
function warmBookCover(book, priority = "low") {
const url = bookCoverPreloadUrl(book);
if (!url) return null;
const existing = bookCoverPreloadCache.get(url);
const now = performance.now();
if (existing) {
if (existing.status === "failed" && priority === "high") {
bookCoverPreloadCache.delete(url);
} else {
existing.lastUsedAt = now;
if (priority === "high" && existing.image && "fetchPriority" in existing.image) {
existing.image.fetchPriority = "high";
}
return existing.promise;
}
}
const image = new Image();
image.decoding = "async";
image.loading = "eager";
if ("fetchPriority" in image) image.fetchPriority = priority;
const entry = {
image,
status: "loading",
lastUsedAt: now,
promise: null,
};
let settled = false;
entry.promise = new Promise((resolve) => {
const finish = (status) => {
if (settled) return;
settled = true;
entry.status = status;
entry.lastUsedAt = performance.now();
resolve(entry);
};
image.addEventListener("load", () => finish("ready"), { once: true });
image.addEventListener("error", () => finish("failed"), { once: true });
image.src = url;
if (image.complete) finish(image.naturalWidth > 0 ? "ready" : "failed");
});
bookCoverPreloadCache.set(url, entry);
pruneBookCoverPreloadCache();
return entry.promise;
}
function pruneBookCoverPreloadCache() {
if (bookCoverPreloadCache.size <= BOOK_COVER_PRELOAD_LIMIT) return;
const removable = Array.from(bookCoverPreloadCache.entries())
.filter(([, entry]) => entry.status !== "loading")
.sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt);
while (bookCoverPreloadCache.size > BOOK_COVER_PRELOAD_LIMIT && removable.length) {
bookCoverPreloadCache.delete(removable.shift()[0]);
}
}
function warmBookCoversForPoint(point, options = {}) {
const books = pointBooks(point);
if (!books.length) return;
const activeIndex = activeBookIndex(point);
const visibleIndexes = ticketStackVisibleIndexes(books.length, activeIndex);
visibleIndexes.forEach((bookIndex, index) => {
warmBookCover(books[bookIndex], index === 0 ? (options.priority || "high") : "low");
});
if (options.includeNeighbors === false) return;
for (let offset = 1; offset <= BOOK_COVER_PRELOAD_NEIGHBORS; offset += 1) {
warmBookCover(books[(activeIndex + offset) % books.length], "low");
}
}
function captureTicketStackRects(ticketView) {
const rects = new Map();
ticketStackMotionItems(ticketView).forEach((ticket) => {
rects.set(ticket.dataset.bookIndex, ticket.getBoundingClientRect());
});
return rects;
}
function animateBookTicketStack(ticketView, previousRects) {
if (!previousRects.size) {
ticketView.classList.remove("is-prepping-stack-motion");
return;
}
const tickets = ticketStackMotionItems(ticketView);
let moved = false;
tickets.forEach((ticket) => {
const previous = previousRects.get(ticket.dataset.bookIndex);
if (!previous) return;
const next = ticket.getBoundingClientRect();
const deltaX = previous.left - next.left;
const deltaY = previous.top - next.top;
if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) return;
ticket.style.setProperty("--ticket-stack-x", `${deltaX}px`);
ticket.style.setProperty("--ticket-stack-motion-y", `${deltaY}px`);
ticket.classList.add("is-moving-stack");
moved = true;
});
if (!moved) {
ticketView.classList.remove("is-prepping-stack-motion");
return;
}
ticketView.getBoundingClientRect();
requestAnimationFrame(() => {
ticketView.classList.remove("is-prepping-stack-motion");
tickets.forEach((ticket) => {
ticket.style.setProperty("--ticket-stack-x", "0px");
ticket.style.setProperty("--ticket-stack-motion-y", "0px");
});
window.setTimeout(() => {
tickets.forEach((ticket) => ticket.classList.remove("is-moving-stack"));
}, 660);
});
}
function ticketStackMotionItems(ticketView) {
const shells = Array.from(ticketView.querySelectorAll(".book-ticket-shell[data-book-index]"));
if (shells.length) return shells;
return Array.from(ticketView.querySelectorAll(".book-ticket[data-book-index]"));
}
function bookTicketTemplate(ticketView) {
if (!bookTicketTemplate.cache) {
const source = ticketView.querySelector(".book-ticket");
if (!source) return null;
bookTicketTemplate.cache = source.cloneNode(true);
}
return bookTicketTemplate.cache;
}
function applyBookTicketStackStyle(ticket, index, count) {
const selected = index === 0;
const stackLift = [-10, -2, 6];
const lift = stackLift[index] ?? 6 + index * 6;
const zIndex = count - index + (selected ? 8 : 0);
const layerStrength = Math.min(index, 4);
ticket.style.setProperty("--ticket-stack-lift", `${lift}px`);
ticket.style.setProperty("--ticket-stack-rotate", "0deg");
ticket.style.setProperty("--ticket-stack-z", String(zIndex));
ticket.style.setProperty("--ticket-stack-overlap", index === 0 ? "0px" : `${TICKET_STACK_PEEK - 222}px`);
ticket.style.setProperty("--ticket-stack-depth", String(layerStrength));
ticket.style.setProperty("--ticket-cast-alpha", `${0.13 + layerStrength * 0.025}`);
ticket.style.setProperty("--ticket-cast-x", `${7 + layerStrength * 2}px`);
ticket.style.setProperty("--ticket-cast-y", `${15 + layerStrength * 3}px`);
}
function selectBookTicket(ticket, options = {}) {
const point = state.activePoint;
if (!point) return;
const index = Number(ticket.dataset.bookIndex);
if (!Number.isFinite(index)) return;
state.activeBookIndexes.set(point.id, clampBookIndex(point, index));
warmBookCoversForPoint(point, { priority: "high" });
warmShareHalftoneQueue();
trackBookTicketOpened(point, "book_ticket_stack", {
input_method: options.rerender ? "pointer" : "keyboard",
});
if (options.rerender) {
playUiSound("pageFlick");
updateTooltipContent(point);
}
}
function shouldKeepTooltipTextSelection(event) {
const pointer = state.tooltipPointer;
state.tooltipPointer = null;
const movedDistance = pointer
? Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y)
: 0;
return movedDistance > 4 || hasTextSelectionInside(elements.cityTooltip);
}
function hasTextSelectionInside(root) {
const selection = window.getSelection?.();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return false;
for (let index = 0; index < selection.rangeCount; index += 1) {
const range = selection.getRangeAt(index);
if (root.contains(range.commonAncestorContainer)) return true;
}
return false;
}
function fillBookCard(root, city, book) {
const specialContext = city?.specialContext;
root.classList.toggle("is-special-book-card", Boolean(specialContext));
if (root instanceof Element) {
root.dataset.bookId = book.bookId || book.id || "";
}
root.querySelectorAll?.(".book-ticket, .share-ticket").forEach((ticket) => {
ticket.dataset.bookId = book.bookId || book.id || "";
});
const title = fullBookTitle(book, city);
const coverUrl = book.coverUrl || "";
const coverImages = Array.from(root.querySelectorAll(".book-ticket__cover img, .book-card__detail-cover img, .share-ticket__cover img"));
const coverPriority = root.matches?.(".book-ticket") && !root.classList.contains("is-selected") ? "low" : "high";
let pendingCovers = coverUrl ? coverImages.length : 0;
const coverToken = `${bookIdentity(book)}:${coverUrl}`;
setBookCardCoverPending(root, pendingCovers > 0);
root.querySelectorAll(".book-card__cover-fallback").forEach((fallback) => {
fallback.hidden = Boolean(coverUrl);
});
const finishCover = (cover, loaded) => {
if (cover.dataset.coverToken !== coverToken) return;
if (cover.dataset.coverReadyToken === coverToken) return;
cover.dataset.coverReadyToken = coverToken;
if (!loaded) {
cover.hidden = true;
root.querySelectorAll(".book-card__cover-fallback").forEach((fallback) => {
fallback.hidden = false;
});
}
scheduleTicketCutPaths(root);
scheduleDetailCoverFit(root);
scheduleActiveTooltipPlacement();
pendingCovers -= 1;
if (pendingCovers <= 0) {
setBookCardCoverPending(root, false);
revealBookTicketShellWhenReady(root);
}
};
coverImages.forEach((cover) => {
cover.dataset.coverToken = coverToken;
cover.alt = title ? `${title}封面` : "";
cover.hidden = !coverUrl;
if (!coverUrl) {
cover.removeAttribute("src");
return;
}
cover.decoding = "async";
cover.loading = "eager";
if ("fetchPriority" in cover) cover.fetchPriority = coverPriority;
cover.addEventListener("error", () => finishCover(cover, false), { once: true });
cover.addEventListener("load", () => {
finishCover(cover, true);
}, { once: true });
cover.decode?.().then(() => {
finishCover(cover, true);
}).catch(() => {});
cover.src = coverUrl;
if (cover.complete) finishCover(cover, cover.naturalWidth > 0);
});
root.querySelectorAll(".book-card__location-zh").forEach((element) => {
element.textContent = bookCardLocationLine(city);
});
root.querySelectorAll(".book-card__location-en").forEach((element) => {
element.textContent = "";
element.hidden = !element.textContent;
});
root.querySelectorAll(".book-card__title-zh").forEach((element) => {
renderBookTitle(element, titleLayoutForBook(book, city));
});
root.querySelectorAll(".book-card__title-original").forEach((element) => {
element.textContent = book.titleOriginal || "";
element.hidden = !element.textContent;
element.classList.remove("is-share-title-original-long");
element.style.removeProperty("--detail-title-original-scale");
element.style.removeProperty("--share-title-original-scale");
});
root.querySelectorAll(".book-card__meta").forEach((element) => {
renderBookMeta(element, book, { split: false });
});
root.querySelectorAll(".book-card__shareline").forEach((element) => {
const text = shortBookText(book);
element.textContent = text;
element.hidden = !text;
});
root.querySelectorAll(".book-card__intro").forEach((element) => {
renderDetailBookIntro(element, book);
});
root.querySelectorAll(".book-card__quote").forEach((element) => {
const text = shortBookText(book);
element.textContent = text;
element.hidden = !text;
});
scheduleBookMetaFit(root);
scheduleTicketCutPaths(root);
scheduleDetailCoverFit(root);
if (pendingCovers <= 0) revealBookTicketShellWhenReady(root);
}
function setBookCardCoverPending(root, isPending) {
root.classList.toggle("is-cover-pending", isPending);
root.closest?.(".book-ticket-shell, .share-ticket-shell")?.classList.toggle("is-cover-pending", isPending);
}
function revealBookTicketShellWhenReady(root) {
const shell = root.closest?.(".book-ticket-shell");
if (!shell || !shell.classList.contains("is-entering") || shell.classList.contains("is-cover-pending")) return;
if (shell.dataset.enterQueued === "1") return;
shell.dataset.enterQueued = "1";
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
if (!shell.isConnected || shell.classList.contains("is-cover-pending")) {
delete shell.dataset.enterQueued;
return;
}
const finish = () => {
shell.classList.remove("is-entering", "is-reveal-ready");
delete shell.dataset.enterQueued;
shell.removeEventListener("animationend", finish);
};
shell.addEventListener("animationend", finish, { once: true });
shell.classList.add("is-reveal-ready");
window.setTimeout(finish, 780);
});
});
}
function specialBookTicketLocationZh(city, specialContext) {
const cityName = city?.cityZh || city?.cityEn || "";
return [cityName, specialContext?.category].filter(Boolean).join(" · ");
}
function specialBookTicketLocationEn(city, specialContext) {
const cityName = city?.cityEn || city?.cityZh || "";
return [cityName, specialContext?.categoryEn].filter(Boolean).join(" · ");
}
function specialBookTicketLocationLine(city, specialContext) {
return [
specialBookTicketLocationZh(city, specialContext),
specialBookTicketLocationEn(city, specialContext),
].filter(Boolean).join(" | ");
}
function bookCardLocationLine(city) {
const specialContext = city?.specialContext;
return specialContext ? specialBookTicketLocationLine(city, specialContext) : combinedLocationHeading(city);
}
function scheduleBookMetaFit(root) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
fitBookMetaLine(root);
fitDetailOriginalTitle(root);
fitDetailSubtitleTitle(root);
fitShareOriginalTitle(root);
syncDetailIntroOverflow(root);
updateTicketCutPaths(root);
});
});
document.fonts?.ready.then(() => {
fitBookMetaLine(root);
fitDetailOriginalTitle(root);
fitDetailSubtitleTitle(root);
fitShareOriginalTitle(root);
syncDetailIntroOverflow(root);
updateTicketCutPaths(root);
});
}
function fitDetailOriginalTitle(root) {
const titles = [];
if (root instanceof Element && root.matches(".book-card__detail-view")) {
const title = root.querySelector(".book-card__title-original");
if (title) titles.push(title);
}
root.querySelectorAll?.(".book-card__detail-view .book-card__title-original").forEach((element) => titles.push(element));
titles.forEach((element) => {
if (element.hidden || !element.textContent.trim() || !element.getClientRects().length) return;
element.style.removeProperty("--detail-title-original-scale");
const availableWidth = element.clientWidth;
const contentWidth = element.scrollWidth;
if (!availableWidth || !contentWidth || contentWidth <= availableWidth) return;
const scale = Math.max(0.78, Math.min(1, availableWidth / contentWidth));
element.style.setProperty("--detail-title-original-scale", String(Math.round(scale * 1000) / 1000));
});
}
function fitDetailSubtitleTitle(root) {
const titles = [];
if (root instanceof Element && root.matches(".book-card__detail-view")) {
root.querySelectorAll(".book-card__title-zh.is-title-subtitle .book-card__title-sub").forEach((element) => titles.push(element));
}
root.querySelectorAll?.(".book-card__detail-view .book-card__title-zh.is-title-subtitle .book-card__title-sub").forEach((element) => titles.push(element));
titles.forEach((element) => {
if (element.hidden || !element.textContent.trim() || !element.getClientRects().length) return;
element.style.removeProperty("--detail-title-sub-scale");
const availableWidth = element.parentElement?.clientWidth || element.clientWidth || 0;
const contentWidth = element.scrollWidth;
if (!availableWidth || !contentWidth || contentWidth <= availableWidth) return;
const minScale = element.parentElement?.classList.contains("is-title-long-subtitle") ? 0.62 : 0.72;
const scale = Math.max(minScale, Math.min(1, availableWidth / contentWidth));
element.style.setProperty("--detail-title-sub-scale", String(Math.round(scale * 1000) / 1000));
});
}
function fitShareOriginalTitle(root) {
const titles = [];
if (root instanceof Element && root.matches(".share-ticket")) {
const title = root.querySelector(".book-card__title-original");
if (title) titles.push(title);
}
root.querySelectorAll?.(".share-ticket .book-card__title-original").forEach((element) => titles.push(element));
titles.forEach((element) => {
element.classList.remove("is-share-title-original-long");
if (element.hidden || !element.textContent.trim() || !element.getClientRects().length) return;
element.style.removeProperty("--share-title-original-scale");
const rawAvailableWidth = element.clientWidth || element.parentElement?.clientWidth || 0;
const availableWidth = Math.max(1, rawAvailableWidth - SHARE_TITLE_ORIGINAL_SAFE_INSET);
const contentWidth = element.scrollWidth;
if (!availableWidth || !contentWidth || contentWidth <= availableWidth) return;
const minScale = isLongLatinShareTitle(element.textContent) ? SHARE_TITLE_ORIGINAL_LONG_LATIN_MIN_SCALE : SHARE_TITLE_ORIGINAL_MIN_SCALE;
const scale = Math.max(minScale, Math.min(1, availableWidth / contentWidth));
element.style.setProperty("--share-title-original-scale", String(Math.round(scale * 1000) / 1000));
element.classList.toggle("is-share-title-original-long", scale < SHARE_TITLE_ORIGINAL_MIN_SCALE);
});
}
function isLongLatinShareTitle(text) {
const title = String(text || "").trim();
if (title.length >= 56 && /^[\x20-\x7E]+$/.test(title)) return true;
const latinLetterCount = (title.match(/[A-Za-z]/g) || []).length;
return title.length >= 60 && latinLetterCount / Math.max(1, title.length) > 0.58;
}
const DETAIL_INTRO_FIT_STEPS = [
{ font: 1, line: 1.58, gap: 16, top: 18, bottom: 8 },
{ font: 0.98, line: 1.54, gap: 13, top: 15, bottom: 6 },
{ font: 0.95, line: 1.5, gap: 11, top: 13, bottom: 4 },
{ font: 0.92, line: 1.45, gap: 9, top: 11, bottom: 2 },
{ font: 0.89, line: 1.4, gap: 7, top: 9, bottom: 0 },
{ font: 0.86, line: 1.36, gap: 6, top: 8, bottom: 0 },
];
function clearDetailIntroFit(element) {
element.classList.remove("is-overflowing", "is-fit-tight");
element.style.removeProperty("--detail-intro-font-scale");
element.style.removeProperty("--detail-intro-line-height");
element.style.removeProperty("--detail-intro-gap");
element.style.removeProperty("--detail-intro-margin-top");
element.style.removeProperty("--detail-intro-margin-bottom");
}
function applyDetailIntroFitStep(element, step) {
element.style.setProperty("--detail-intro-font-scale", String(step.font));
element.style.setProperty("--detail-intro-line-height", String(step.line));
element.style.setProperty("--detail-intro-gap", `${step.gap}px`);
element.style.setProperty("--detail-intro-margin-top", `${step.top}px`);
element.style.setProperty("--detail-intro-margin-bottom", `${step.bottom}px`);
}
function detailIntroHasOverflow(element) {
return element.scrollHeight > element.clientHeight + 1 || element.scrollWidth > element.clientWidth + 1;
}
function syncDetailIntroOverflow(root) {
const intros = [];
if (root instanceof Element && root.matches(".book-card__detail-view")) {
const intro = root.querySelector(".book-card__intro");
if (intro) intros.push(intro);
}
root.querySelectorAll?.(".book-card__detail-view .book-card__intro").forEach((element) => intros.push(element));
intros.forEach((element) => {
if (!element.getClientRects().length) return;
clearDetailIntroFit(element);
for (const step of DETAIL_INTRO_FIT_STEPS) {
applyDetailIntroFitStep(element, step);
if (!detailIntroHasOverflow(element)) return;
}
element.classList.add("is-fit-tight");
});
}
function scheduleTicketCutPaths(root = document) {
requestAnimationFrame(() => {
requestAnimationFrame(() => updateTicketCutPaths(root));
});
}
function scheduleDetailCoverFit(root = document) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
updateDetailCoverFit(root);
if (state.bookCardMode === "detail") placeDetailTooltip();
});
});
document.fonts?.ready.then(() => {
updateDetailCoverFit(root);
if (state.bookCardMode === "detail") placeDetailTooltip();
});
}
function scheduleActiveTooltipPlacement() {
if (!state.activePoint || elements.cityTooltip.hidden) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (state.activePoint && !elements.cityTooltip.hidden) {
if (state.bookCardMode === "detail") {
placeDetailTooltip();
} else {
syncTooltip(createProjection(), { skipTooltipContent: true });
}
}
});
});
}
function updateDetailCoverFit(root = document) {
const detailViews = [];
if (root instanceof Element && root.matches(".book-card__detail-view")) {
detailViews.push(root);
}
root.querySelectorAll?.(".book-card__detail-view").forEach((view) => detailViews.push(view));
detailViews.forEach((view) => {
if (!view.getClientRects().length) return;
const cover = view.querySelector(".book-card__detail-cover");
const coverImage = view.querySelector(".book-card__detail-cover img");
if (!cover || !coverImage || coverImage.hidden) return;
const naturalRatio = coverImage.naturalWidth && coverImage.naturalHeight
? coverImage.naturalWidth / coverImage.naturalHeight
: 0.7;
const baseViewStyle = getComputedStyle(view);
const computedWideCoverThreshold = Number.parseFloat(baseViewStyle.getPropertyValue("--detail-wide-cover-threshold"));
const wideCoverThreshold = Number.isFinite(computedWideCoverThreshold) && computedWideCoverThreshold > 0
? computedWideCoverThreshold
: 0.94;
const isWideDetailCover = naturalRatio >= wideCoverThreshold;
view.classList.toggle("is-wide-detail-cover", isWideDetailCover);
const viewStyle = getComputedStyle(view);
const computedCoverHeight = Number.parseFloat(viewStyle.getPropertyValue("--detail-cover-height"));
const computedMaxCoverRatio = Number.parseFloat(viewStyle.getPropertyValue("--detail-cover-max-ratio"));
const computedWideCoverGap = Number.parseFloat(viewStyle.getPropertyValue("--detail-wide-cover-gap"));
const computedSafeWidth = Number.parseFloat(viewStyle.getPropertyValue("--tooltip-safe-width"));
const computedCopyMinWidth = Number.parseFloat(viewStyle.getPropertyValue("--detail-copy-min-width"));
const coverHeight = Number.isFinite(computedCoverHeight)
? computedCoverHeight
: Math.ceil(cover.getBoundingClientRect().height || 420);
const maxCoverRatio = Number.isFinite(computedMaxCoverRatio) && computedMaxCoverRatio > 0
? computedMaxCoverRatio
: 1.12;
const wideCoverGap = Number.isFinite(computedWideCoverGap) && computedWideCoverGap > 0
? computedWideCoverGap
: 40;
const safeWidth = Number.isFinite(computedSafeWidth) && computedSafeWidth > 0
? computedSafeWidth
: Math.ceil(view.closest(".book-card")?.getBoundingClientRect().width || window.innerWidth - 44);
const copyMinWidth = Number.isFinite(computedCopyMinWidth) && computedCopyMinWidth > 0
? computedCopyMinWidth
: 360;
const columnGap = Number.parseFloat(viewStyle.columnGap) || Number.parseFloat(viewStyle.gap) || 0;
const effectiveColumnGap = isWideDetailCover ? wideCoverGap : columnGap;
const horizontalPadding = (Number.parseFloat(viewStyle.paddingLeft) || 0)
+ (Number.parseFloat(viewStyle.paddingRight) || 0);
const availableCoverWidth = Math.max(96, safeWidth - copyMinWidth - effectiveColumnGap - horizontalPadding);
const coverRatio = Math.min(naturalRatio, maxCoverRatio);
const naturalCoverWidth = coverHeight * naturalRatio;
const maxRatioCoverWidth = coverHeight * coverRatio;
const desiredCoverWidth = isWideDetailCover ? naturalCoverWidth : maxRatioCoverWidth;
const coverWidth = Math.max(96, Math.min(naturalCoverWidth, desiredCoverWidth, availableCoverWidth));
view.style.setProperty("--detail-cover-height", `${Math.round(coverHeight)}px`);
view.style.setProperty("--detail-cover-width", `${Math.round(coverWidth)}px`);
view.style.setProperty("--detail-cover-fit", isWideDetailCover ? "contain" : (naturalRatio > coverRatio ? "cover" : "contain"));
});
}
function updateTicketCutPaths(root = document) {
ticketCutTargets(root).forEach((ticket) => {
const rect = ticket.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const box = ticketLayoutBox(ticket, rect);
const radius = ticketCutRadius(box.width);
const spacingChanged = syncTicketCutSpacing(ticket, radius);
if (spacingChanged) {
requestAnimationFrame(() => updateTicketCutPaths(ticket));
}
const updatedRect = ticket.getBoundingClientRect();
if (!updatedRect.width || !updatedRect.height) return;
const updatedBox = ticketLayoutBox(ticket, updatedRect);
const dividerY = ticketDividerY(ticket, updatedRect, updatedBox, radius);
const path = ticketShapePath(updatedBox.width, updatedBox.height, radius, dividerY);
const clipPath = `path("${path}")`;
ticket.style.clipPath = clipPath;
ticket.style.webkitClipPath = clipPath;
ticket.style.setProperty("-webkit-clip-path", clipPath);
});
}
function ticketCutTargets(root) {
const targets = [];
if (root instanceof Element && root.matches(".book-ticket, .share-ticket, .special-book__bookmark")) {
targets.push(root);
}
root.querySelectorAll?.(".book-ticket, .share-ticket, .special-book__bookmark").forEach((ticket) => targets.push(ticket));
return targets;
}
function ticketLayoutBox(ticket, rect) {
return {
width: ticket.offsetWidth || ticket.clientWidth || rect.width,
height: ticket.offsetHeight || ticket.clientHeight || rect.height,
};
}
function syncTicketCutSpacing(ticket, radius) {
if (ticket.matches(".special-book__bookmark")) return false;
const actions = ticket.querySelector(".book-card__ticket-actions");
const actionRect = actions?.getBoundingClientRect();
const actionHeight = actionRect?.height || 0;
const bodyBottomPadding = Math.round(Math.max(5, radius * 0.42));
const actionBottomGap = Math.round(Math.min(12, Math.max(radius * 0.68, actionHeight * 0.16)));
const bodyPaddingChanged = setPixelProperty(ticket, "--ticket-body-bottom-padding", bodyBottomPadding);
const actionGapChanged = setPixelProperty(ticket, "--ticket-action-bottom-gap", actionBottomGap);
return bodyPaddingChanged || actionGapChanged;
}
function setPixelProperty(element, name, value) {
const nextValue = `${value}px`;
if (element.style.getPropertyValue(name) !== nextValue) {
element.style.setProperty(name, nextValue);
return true;
}
return false;
}
function ticketDividerY(ticket, rect, box, radius) {
if (ticket.matches(".special-book__bookmark")) {
const rule = ticket.querySelector(".special-book__rule");
const ruleRect = rule?.getBoundingClientRect();
const yScale = rect.height ? box.height / rect.height : 1;
const rawDividerY = ruleRect ? (ruleRect.top + ruleRect.height / 2 - rect.top) * yScale : box.height * 0.68;
return clamp(rawDividerY, radius + 1, Math.max(radius + 1, box.height - radius - 1));
}
const meta = ticket.querySelector(".book-card__meta");
const actions = ticket.querySelector(".book-card__ticket-actions");
const metaRect = meta?.getBoundingClientRect();
const actionsRect = actions?.getBoundingClientRect();
const yScale = rect.height ? box.height / rect.height : 1;
const rawDividerY = metaRect ? (metaRect.top - rect.top) * yScale : box.height * 0.64;
const maxDividerY = actionsRect
? (actionsRect.top - rect.top) * yScale - radius - 8
: box.height - radius - 1;
return clamp(rawDividerY, radius + 1, Math.max(radius + 1, maxDividerY));
}
function ticketCutRadius(width) {
return Math.min(TICKET_CUT_MAX_RADIUS, width * 0.044);
}
function ticketShapePath(width, height, radius, dividerY) {
const w = roundPathNumber(width);
const r = roundPathNumber(radius);
const bottomY = roundPathNumber(height);
const sideY = roundPathNumber(Math.min(Math.max(dividerY, r + 1), height - r - 1));
const bottomCenters = bottomCutCenters(width, radius).map(roundPathNumber).sort((a, b) => b - a);
const parts = [
`M0 0`,
`H${w}`,
`V${roundPathNumber(sideY - r)}`,
`A${r} ${r} 0 0 0 ${w} ${roundPathNumber(sideY + r)}`,
`V${roundPathNumber(bottomY - r)}`,
`A${r} ${r} 0 0 0 ${roundPathNumber(w - r)} ${bottomY}`,
];
bottomCenters.forEach((center) => {
parts.push(`H${roundPathNumber(center + radius)}`);
parts.push(`A${r} ${r} 0 0 0 ${roundPathNumber(center - radius)} ${bottomY}`);
});
parts.push(
`H${r}`,
`A${r} ${r} 0 0 0 0 ${roundPathNumber(bottomY - r)}`,
`V${roundPathNumber(sideY + r)}`,
`A${r} ${r} 0 0 0 0 ${roundPathNumber(sideY - r)}`,
`V0Z`,
);
return parts.join(" ");
}
function bottomCutCenters(width, radius) {
const cutCount = Math.max(7, Math.round(width / (radius * 3.3)));
const spacing = width / (cutCount - 1);
return Array.from({ length: cutCount - 2 }, (_, index) => spacing * (index + 1))
.filter((center) => center - radius > 0 && center + radius < width);
}
function roundPathNumber(value) {
return Math.round(value * 10) / 10;
}
function fitBookMetaLine(root) {
root.querySelectorAll(".book-ticket .book-card__meta, .share-ticket .book-card__meta").forEach((element) => {
const authors = element.dataset.authors || "";
const details = element.dataset.details || "";
element.style.fontSize = "";
element.style.setProperty("--book-meta-scale", "1");
element.classList.remove("is-meta-split");
renderBookMeta(element, { authors: authors ? [authors] : [], publisher: details }, { split: false, fromDataset: true });
const availableWidth = element.clientWidth;
const contentWidth = element.scrollWidth;
if (!availableWidth || !contentWidth || contentWidth <= availableWidth) return;
renderBookMeta(element, { authors: authors ? [authors] : [], publisher: details }, { split: true, fromDataset: true });
element.classList.add("is-meta-split");
fitBookMetaAuthorLine(element);
});
}
function fitBookMetaAuthorLine(element) {
const authorLine = element.querySelector(".book-card__meta-authors");
if (!authorLine) return;
authorLine.classList.remove("is-meta-author-compressed");
authorLine.style.setProperty("--book-meta-author-scale", "1");
const styles = getComputedStyle(element);
const paddingLeft = Number.parseFloat(styles.paddingLeft) || 0;
const paddingRight = Number.parseFloat(styles.paddingRight) || 0;
const availableWidth = Math.max(0, element.clientWidth - paddingLeft - paddingRight - 2);
const contentWidth = authorLine.scrollWidth;
if (!availableWidth || !contentWidth || contentWidth <= availableWidth) return;
const scale = Math.max(0.68, Math.min(1, availableWidth / contentWidth));
authorLine.style.setProperty("--book-meta-author-scale", String(Math.round(scale * 1000) / 1000));
authorLine.classList.add("is-meta-author-compressed");
}
function loadSavedBooks() {
try {
state.savedBookRecords = normalizeSavedBookRecords(JSON.parse(localStorage.getItem(SAVED_BOOKS_STORAGE_KEY) || "[]"));
} catch (error) {
state.savedBookRecords = [];
}
syncSavedBookSet();
}
function persistSavedBooks() {
try {
localStorage.setItem(SAVED_BOOKS_STORAGE_KEY, JSON.stringify(state.savedBookRecords));
} catch (error) {
// Saved books should keep working in-memory even when browser storage is unavailable.
}
}
function syncSavedBookSet() {
state.savedBooks = new Set(state.savedBookRecords.map((record) => record.bookId));
}
function openBookshelf() {
state.bookshelfOpen = true;
state.bookshelfHoverPointer = null;
closeSharePreview({ reason: "bookshelf_opened" });
state.bookshelfPage = Math.min(state.bookshelfPage, bookshelfTotalPages() - 1);
document.documentElement.classList.add("is-bookshelf-view");
syncSpecialBackButton();
trackEvent("bookshelf_opened", {
shelf_count: state.savedBooks.size,
page: state.bookshelfPage + 1,
});
renderBookshelf();
}
function closeBookshelf(options = {}) {
state.bookshelfOpen = false;
if (!options.preserveDetailReturn) state.detailReturnTarget = null;
document.documentElement.classList.remove("is-bookshelf-view");
if (elements.bookshelfView) elements.bookshelfView.hidden = true;
syncSpecialBackButton();
}
function pageBookshelf(direction) {
const pagination = paginateShelfItems(savedShelfItems(), {
page: state.bookshelfPage + direction,
pageSize: bookshelfPageSize(),
});
if (pagination.page === state.bookshelfPage) return;
state.bookshelfPage = pagination.page;
playUiSound("pageFlick");
renderBookshelf();
}
function bookshelfTotalPages() {
return paginateShelfItems(savedShelfItems(), {
page: state.bookshelfPage,
pageSize: bookshelfPageSize(),
}).totalPages;
}
function renderBookshelf() {
if (!elements.bookshelfView || !elements.bookshelfGrid) return;
const savedItems = savedShelfItems();
const pagination = paginateShelfItems(savedItems, {
page: state.bookshelfPage,
pageSize: bookshelfPageSize(),
});
state.bookshelfPage = pagination.page;
elements.bookshelfView.hidden = !state.bookshelfOpen;
if (elements.bookshelfCount) {
elements.bookshelfCount.textContent = String(savedItems.length);
elements.bookshelfCount.hidden = savedItems.length === 0;
}
elements.bookshelfToggle?.classList.toggle("has-saved-books", savedItems.length > 0);
elements.bookshelfToggle?.setAttribute("aria-label", savedItems.length ? `打开我的书架,已收藏 ${savedItems.length} 本` : "打开我的书架");
if (elements.bookshelfEmpty) elements.bookshelfEmpty.hidden = savedItems.length > 0;
if (elements.bookshelfPager) elements.bookshelfPager.hidden = pagination.totalPages <= 1;
if (elements.bookshelfPageLabel) elements.bookshelfPageLabel.textContent = `${pagination.page + 1} / ${pagination.totalPages}`;
if (elements.bookshelfPrev) elements.bookshelfPrev.disabled = pagination.page <= 0;
if (elements.bookshelfNext) elements.bookshelfNext.disabled = pagination.page >= pagination.totalPages - 1;
const nextSignature = bookshelfRenderSignature(pagination, savedItems);
if (state.bookshelfRenderSignature === nextSignature) {
restoreBookshelfHoverAfterRender();
return;
}
state.bookshelfRenderSignature = nextSignature;
const rows = buildFixedShelfRows(pagination.items, {
rowSize: bookshelfRowSize(),
rowCount: 2,
});
elements.bookshelfGrid.replaceChildren(...rows.map(renderBookshelfRow));
restoreBookshelfHoverAfterRender();
}
function bookshelfRenderSignature(pagination, savedItems) {
return [
state.bookshelfOpen ? "open" : "closed",
pagination.page,
pagination.pageSize,
pagination.totalPages,
bookshelfRowSize(),
savedItems.map((item) => item.book.bookId).join(","),
].join("|");
}
function bookshelfPageSize() {
return window.matchMedia("(max-width: 820px)").matches ? BOOKSHELF_MOBILE_PAGE_SIZE : BOOKSHELF_DESKTOP_PAGE_SIZE;
}
function bookshelfRowSize() {
return window.matchMedia("(max-width: 820px)").matches ? BOOKSHELF_MOBILE_ROW_SIZE : BOOKSHELF_DESKTOP_ROW_SIZE;
}
function renderBookshelfRow(items, rowIndex) {
const row = document.createElement("div");
row.className = "bookshelf-row";
row.dataset.row = String(rowIndex + 1);
items.forEach((item, index) => {
row.append(renderBookshelfBook(item, index));
});
return row;
}
function syncBookshelfHoverFromPointer(event) {
state.bookshelfHoverPointer = {
clientX: event.clientX,
clientY: event.clientY,
};
restoreBookshelfHoverFromPointer();
}
function restoreBookshelfHoverAfterRender() {
if (!state.bookshelfHoverPointer) return;
requestAnimationFrame(restoreBookshelfHoverFromPointer);
}
function setupBookshelfHoverObserver() {
if (!elements.bookshelfGrid || state.bookshelfHoverObserver) return;
state.bookshelfHoverObserver = new MutationObserver(() => restoreBookshelfHoverAfterRender());
state.bookshelfHoverObserver.observe(elements.bookshelfGrid, { childList: true, subtree: true });
}
function restoreBookshelfHoverFromPointer() {
const pointer = state.bookshelfHoverPointer;
if (!pointer) return;
syncBookshelfHoverAtPoint(pointer.clientX, pointer.clientY);
}
function syncBookshelfHoverAtPoint(clientX, clientY) {
const target = bookshelfBookFromPoint(clientX, clientY);
elements.bookshelfGrid?.querySelectorAll(".bookshelf-book.is-hovered").forEach((book) => {
if (book !== target) book.classList.remove("is-hovered");
});
target?.classList.add("is-hovered");
}
function bookshelfBookFromPoint(clientX, clientY) {
const directTarget = document.elementFromPoint(clientX, clientY)?.closest(".bookshelf-book");
if (directTarget) return directTarget;
const books = [...(elements.bookshelfGrid?.querySelectorAll(".bookshelf-book") || [])];
return books.find((book) => {
const rect = book.getBoundingClientRect();
return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
}) || null;
}
function clearBookshelfHover() {
state.bookshelfHoverPointer = null;
elements.bookshelfGrid?.querySelectorAll(".bookshelf-book.is-hovered").forEach((book) => {
book.classList.remove("is-hovered");
});
}
function renderBookshelfBook(item, index) {
const button = document.createElement("button");
button.className = "bookshelf-book";
button.type = "button";
button.dataset.bookId = item.book.bookId || "";
button.style.setProperty("--book-lean", `${((index % 5) - 2) * 0.42}deg`);
button.setAttribute("aria-label", `打开 ${fullBookTitle(item.book, item.city) || "收藏书籍"}`);
const cover = document.createElement("span");
cover.className = "bookshelf-book__cover";
if (item.book.coverUrl) {
const image = document.createElement("img");
image.src = item.book.coverUrl;
image.alt = "";
image.loading = "lazy";
cover.append(image);
} else {
const fallback = document.createElement("span");
fallback.className = "bookshelf-book__fallback";
fallback.textContent = "BOOK";
cover.append(fallback);
}
const info = document.createElement("span");
info.className = "bookshelf-book__info";
const location = document.createElement("span");
location.className = "bookshelf-book__location";
location.textContent = bookshelfLocationLabel(item.city);
info.append(location);
button.append(cover, info);
button.addEventListener("pointerenter", () => button.classList.add("is-hovered"));
button.addEventListener("pointerover", () => button.classList.add("is-hovered"));
button.addEventListener("mouseover", () => button.classList.add("is-hovered"));
button.addEventListener("pointermove", () => button.classList.add("is-hovered"));
button.addEventListener("pointerleave", () => button.classList.remove("is-hovered"));
button.addEventListener("mouseleave", () => button.classList.remove("is-hovered"));
button.addEventListener("focus", () => button.classList.add("is-hovered"));
button.addEventListener("blur", () => button.classList.remove("is-hovered"));
return button;
}
function openShelfBook(bookId) {
const item = savedShelfItems().find((candidate) => candidate.book.bookId === bookId);
if (!item) return;
state.detailReturnTarget = "bookshelf";
closeBookshelf({ preserveDetailReturn: true });
playUiSound("bookmarkOpen");
state.activePoint = item.city;
state.bookCardMode = "detail";
state.detailOrigin = null;
state.activeBookIndexes.set(item.city.id, item.bookIndex);
trackEvent("bookshelf_book_opened", bookAnalyticsProps(item.city, item.book, {
source_view: "bookshelf",
shelf_count: state.savedBooks.size,
}));
trackEvent("book_detail_opened", bookAnalyticsProps(item.city, item.book, {
source_view: "bookshelf",
shelf_count: state.savedBooks.size,
}));
render();
}
function buildSearchIndex() {
return mapData.cities.flatMap((city) => {
const allBooks = selectedBooksForSearch(city);
return allBooks.map((book, bookIndex) => buildSearchRecord(city, book, bookIndex));
});
}
function selectedBooksForSearch(city) {
const books = Array.isArray(city.books) && city.books.length ? city.books : [city];
return books.filter((book) => book && !book.isPlaceholder && bookIsSelected(book));
}
function buildSearchRecord(city, book, bookIndex) {
const title = fullBookTitle(book, city);
const topics = bookSearchTopics(book, city);
const topicExactAliases = bookSearchTopicExactAliases(book);
const aliases = bookSearchAliases(book, city, topics);
const mainText = [`《${title}》`, locationHeading(city, "zh")].filter(Boolean).join(" ");
const metaText = [
searchAuthors(book),
book.publisher,
book.year,
topics.slice(0, 4).join("、"),
].filter(Boolean).join("|");
return {
id: `${city.id}--${book.bookId || book.id || bookIndex}`,
city,
book,
bookIndex,
title,
topics,
mainText,
metaText,
searchGroups: [
{ type: "title", priority: 700, fields: prepareSearchableFields([title, book.titleZh, book.fullTitleZh, book.titleOriginal]) },
{ type: "location", priority: 620, fields: prepareSearchableFields(searchLocationFields(city)) },
{ type: "author", priority: 520, fields: prepareSearchableFields([searchAuthors(book)]) },
{ type: "publisherYear", priority: 420, fields: prepareSearchableFields([book.publisher, String(book.year || "")]) },
{ type: "topicExact", priority: 360, fields: prepareSearchableFields(topicExactAliases) },
{ type: "topic", priority: 330, fields: prepareSearchableFields(topics) },
{ type: "alias", priority: 230, fields: prepareSearchableFields(aliases) },
],
};
}
function searchLocationFields(city) {
const zhParts = [city.countryZh, city.adminUnitZh, city.cityZh].filter(Boolean);
const enParts = [city.countryEn, city.adminUnitEn, city.cityEn].filter(Boolean);
return [
city.countryZh,
city.countryEn,
city.adminUnitZh,
city.adminUnitEn,
city.cityZh,
city.cityEn,
zhParts.join(""),
zhParts.join(" "),
zhParts.join(" · "),
enParts.join(" "),
enParts.join(" · "),
locationHeading(city, "zh"),
locationHeading(city, "en"),
];
}
function bookSearchTopics(book, city) {
const explicit = [
book.primaryTopic,
...(Array.isArray(book.searchTopics) ? book.searchTopics : []),
book["特辑分类"],
...citySearchTopics(city, book),
...(Array.isArray(book.keywords) ? book.keywords : []),
];
const derived = deriveSearchTopicsFromText([book.oneLine, book.summary, book.sourceIntro].join(" "));
return uniqueCleanTokens([...explicit, ...derived]).slice(0, 10);
}
function bookSearchTopicExactAliases(book) {
const evidence = [
book.primaryTopic,
...(Array.isArray(book.searchTopics) ? book.searchTopics : []),
book["特辑分类"],
...(Array.isArray(book.keywords) ? book.keywords : []),
book.oneLine,
book.summary,
book.sourceIntro,
];
return deriveSearchTopicAliasMatchesFromText(evidence.join(" "));
}
function citySearchTopics(city, book = {}) {
const country = city?.countryZh || book?.countryZh || "";
const cityName = city?.cityZh || book?.cityZh || "";
const admin = city?.adminUnitZh || book?.adminUnitZh || "";
const tagText = [
...(Array.isArray(city?.tags) ? city.tags : []),
...(Array.isArray(city?.categories) ? city.categories : []),
...(Array.isArray(book?.tags) ? book.tags : []),
].join(" ");
const topics = [];
if (["中国", "日本", "韩国"].includes(country) || /香港|澳门|台湾/.test(`${cityName} ${admin}`)) {
topics.push("东亚城市");
}
if (tagText.includes("technology_city")) topics.push("科技");
return topics;
}
function bookSearchAliases(book, city, topics) {
const explicit = Array.isArray(book.searchAliases) ? book.searchAliases : [];
const topicAliases = topics.flatMap((topic) => SEARCH_TOPIC_ALIASES[topic] || []);
const pinyinAliases = [
...topics.flatMap((topic) => SEARCH_PINYIN_ALIASES[topic] || []),
...(SEARCH_PINYIN_ALIASES[city.cityZh] || []),
...(SEARCH_PINYIN_ALIASES[city.countryZh] || []),
...(SEARCH_PINYIN_ALIASES[book.cityZh] || []),
...(SEARCH_PINYIN_ALIASES[book.countryZh] || []),
];
return uniqueCleanTokens([...explicit, ...topicAliases, ...pinyinAliases]);
}
function searchAuthors(book) {
return Array.isArray(book.authors) ? book.authors.filter(Boolean).join(" / ") : "";
}
function prepareSearchableFields(fields) {
return uniqueCleanTokens(fields).map((raw) => {
const text = String(raw);
const normalized = normalizeSearchText(text);
const aliasTokens = (SEARCH_PINYIN_ALIASES[text.trim()] || []).map(normalizeSearchText).filter(Boolean);
return {
raw: text,
normalized,
compact: normalized.replace(/\s+/g, ""),
aliasTokens,
tokens: searchTokens(text),
};
}).filter((field) => field.normalized);
}
function uniqueCleanTokens(values) {
const seen = new Set();
const output = [];
values.flat().forEach((value) => {
const text = String(value || "").trim();
if (!text || seen.has(text)) return;
seen.add(text);
output.push(text);
});
return output;
}
function searchTokens(text) {
const normalized = normalizeSearchText(text);
const compact = normalized.replace(/\s+/g, "");
return uniqueCleanTokens([
normalized.split(/\s+/),
compact,
...(SEARCH_PINYIN_ALIASES[String(text || "").trim()] || []),
].flat()).map(normalizeSearchText).filter(Boolean);
}
function searchQueryTokens(query) {
const normalized = normalizeSearchText(query);
const compact = normalized.replace(/\s+/g, "");
if (/\s/.test(normalized) && /^[a-z0-9]+$/.test(compact) && compact.length >= 4) {
return uniqueCleanTokens([normalized, compact]).map(normalizeSearchText).filter(Boolean);
}
return searchTokens(normalized);
}
function updateSearchQuery(value, options = {}) {
state.search.query = value;
const query = normalizeSearchText(value);
if (!query || query.length < SEARCH_MIN_QUERY_LENGTH) {
cancelSearchNoResultAnalytics();
state.search.results = [];
state.search.activeIndex = -1;
state.search.open = Boolean(options.openIfEmpty);
renderSearchResults();
scheduleSearchSuggestion();
return;
}
hideSearchSuggestion();
if (!state.analytics.searchStarted) {
state.analytics.searchStarted = true;
trackEvent("search_started", searchAnalyticsProps(query, {
source_view: "search_box",
}));
}
state.search.results = searchRecords(query).slice(0, SEARCH_RESULT_LIMIT);
state.search.activeIndex = state.search.results.length ? 0 : -1;
state.search.open = true;
if (!state.search.results.length) {
scheduleSearchNoResultAnalytics(query);
} else {
cancelSearchNoResultAnalytics();
}
renderSearchResults();
}
function scheduleSearchNoResultAnalytics(query) {
cancelSearchNoResultAnalytics();
if (!shouldTrackSearchNoResultQuery(query) || state.analytics.lastNoResultQuery === query) return;
state.analytics.pendingNoResultQuery = query;
state.analytics.noResultTimer = window.setTimeout(() => {
state.analytics.noResultTimer = null;
const currentQuery = normalizeSearchText(state.search.query);
if (
currentQuery !== state.analytics.pendingNoResultQuery
|| state.search.results.length
|| state.analytics.lastNoResultQuery === currentQuery
|| !shouldTrackSearchNoResultQuery(currentQuery)
) {
return;
}
state.analytics.lastNoResultQuery = currentQuery;
trackEvent("search_no_results", searchAnalyticsProps(currentQuery, {
source_view: "search_box",
query_text: analyticsSearchText(currentQuery),
result_count: 0,
}));
}, SEARCH_NO_RESULT_ANALYTICS_IDLE_MS);
}
function cancelSearchNoResultAnalytics() {
if (state.analytics.noResultTimer) {
window.clearTimeout(state.analytics.noResultTimer);
state.analytics.noResultTimer = null;
}
state.analytics.pendingNoResultQuery = "";
}
function shouldTrackSearchNoResultQuery(query) {
const text = analyticsSearchText(query);
if (text.length < SEARCH_NO_RESULT_ANALYTICS_MIN_LENGTH) return false;
if (/^[a-z0-9]$/iu.test(text)) return false;
return true;
}
function scheduleSearchSuggestion() {
if (state.search.suggestionTimer) {
window.clearTimeout(state.search.suggestionTimer);
state.search.suggestionTimer = null;
}
if (!shouldShowSearchSuggestion()) {
if (elements.searchInput?.value.trim()) hideSearchSuggestion();
return;
}
state.search.suggestionTimer = window.setTimeout(() => {
state.search.suggestionTimer = null;
showSearchSuggestion();
}, SEARCH_SUGGESTION_IDLE_MS);
}
function shouldShowSearchSuggestion() {
return Boolean(
elements.searchInput
&& document.activeElement === elements.searchInput
&& !elements.searchInput.value.trim()
&& !state.search.open
&& !state.search.results.length
&& state.search.index.length,
);
}
function showSearchSuggestion() {
if (!shouldShowSearchSuggestion()) return;
const suggestion = pickSearchSuggestion();
if (!suggestion) {
hideSearchSuggestion();
return;
}
state.search.suggestion = suggestion;
state.search.suggestionRolls += 1;
state.search.shownSuggestions.add(searchSuggestionKey(suggestion));
renderSearchSuggestion();
}
function pickSearchSuggestion() {
const fallbackOrder = ["issue", "publisher", "author"];
const preferredCategory = weightedSearchSuggestionCategory();
const categories = uniqueCleanTokens([preferredCategory, ...fallbackOrder]);
for (const category of categories) {
const candidate = randomSearchSuggestionFromCategory(category);
if (candidate) return candidate;
}
state.search.shownSuggestions.clear();
for (const category of categories) {
const candidate = randomSearchSuggestionFromCategory(category);
if (candidate) return candidate;
}
return null;
}
function weightedSearchSuggestionCategory() {
const roll = state.search.suggestionRolls;
if (roll < 5) return "issue";
const weights = roll < 12
? { issue: 85, publisher: 10, author: 5 }
: { issue: 75, publisher: 15, author: 10 };
const total = Object.values(weights).reduce((sum, value) => sum + value, 0);
let cursor = Math.random() * total;
for (const [category, weight] of Object.entries(weights)) {
cursor -= weight;
if (cursor <= 0) return category;
}
return "issue";
}
function randomSearchSuggestionFromCategory(category) {
const pool = searchSuggestionPool(category)
.map((label) => ({ category, label }))
.filter((suggestion) => !state.search.shownSuggestions.has(searchSuggestionKey(suggestion)))
.filter((suggestion) => searchRecords(normalizeSearchText(suggestion.label)).length > 0);
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
function searchSuggestionPool(category) {
if (category === "publisher") return SEARCH_SUGGESTION_PUBLISHERS;
if (category === "author") return SEARCH_SUGGESTION_AUTHORS;
return SEARCH_SUGGESTION_ISSUES;
}
function searchSuggestionKey(suggestion) {
return `${suggestion.category}:${suggestion.label}`;
}
function renderSearchSuggestion() {
if (!elements.searchSuggestion) return;
const suggestion = state.search.suggestion;
elements.searchSuggestion.hidden = !suggestion;
elements.searchSuggestion.textContent = suggestion?.label || "";
elements.searchSuggestion.dataset.suggestionCategory = suggestion?.category || "";
}
function hideSearchSuggestion() {
if (state.search.suggestionTimer) {
window.clearTimeout(state.search.suggestionTimer);
state.search.suggestionTimer = null;
}
state.search.suggestion = null;
renderSearchSuggestion();
}
function confirmSearchSuggestion() {
const suggestion = state.search.suggestion;
if (!suggestion || !elements.searchInput) return;
playUiSound("softTap");
state.search.acceptedSuggestions += 1;
const value = suggestion.label;
hideSearchSuggestion();
elements.searchInput.value = value;
elements.searchInput.focus();
updateSearchQuery(value);
}
function searchRecords(query) {
const scored = state.search.index
.map((record) => ({ record, match: scoreSearchRecord(record, query) }))
.filter((item) => item.match.score > 0);
scored.sort((a, b) => (
b.match.score - a.match.score
|| b.match.bestPriority - a.match.bestPriority
|| String(a.record.title).localeCompare(String(b.record.title), "zh-Hans-CN")
));
return scored.map((item) => item.record);
}
function scoreSearchRecord(record, query) {
const queryCompact = query.replace(/\s+/g, "");
const queryTokens = searchQueryTokens(query);
const topicQuery = isSearchTopicQuery(query, queryCompact);
const matches = record.searchGroups
.map((group) => scoreSearchGroup(group, query, queryCompact, queryTokens, topicQuery))
.filter((item) => item.score > 0);
if (!matches.length) return { score: 0, bestPriority: 0 };
const best = matches.reduce((winner, item) => (item.score > winner.score ? item : winner), matches[0]);
const auxiliary = matches.reduce((total, item) => total + (item === best ? 0 : Math.min(item.score, 20)), 0);
return {
score: best.score + auxiliary,
bestPriority: best.priority,
};
}
function scoreSearchGroup(group, query, queryCompact, queryTokens, topicQuery = false) {
let bestScore = 0;
group.fields.forEach((field) => {
const score = scoreSearchField(field, query, queryCompact, queryTokens, group.type, topicQuery);
bestScore = Math.max(bestScore, score);
});
return {
priority: group.priority,
score: bestScore ? group.priority + bestScore : 0,
};
}
function scoreSearchField(field, query, queryCompact, queryTokens, groupType, topicQuery = false) {
if (!field.normalized) return 0;
if (field.normalized === query || field.compact === queryCompact) return 60;
if (topicQuery && groupType === "publisherYear") return 0;
if (/^[a-z0-9]{1,2}$/.test(queryCompact)) return 0;
if (field.normalized.startsWith(query) || field.compact.startsWith(queryCompact)) return 46;
if (field.normalized.includes(query) || field.compact.includes(queryCompact)) {
return 34;
}
if (groupType === "publisherYear") return 0;
if (/^[\u4e00-\u9fff]{1,2}$/.test(queryCompact)) return 0;
const isLatinQuery = /^[a-z0-9\s]+$/.test(query) && /[a-z]/.test(queryCompact);
if (isLatinQuery) {
const aliasFuzzy = (field.aliasTokens || []).some((token) => fuzzySearchMatch(queryCompact, token) || fuzzySearchMatch(query, token));
if (aliasFuzzy) return 14;
if (groupType !== "alias") return 0;
}
const fuzzy = field.tokens.some((token) => token && queryTokens.some((queryToken) => fuzzySearchMatch(queryToken, token)));
return fuzzy ? 14 : 0;
}
function isSearchTopicQuery(query, queryCompact) {
const candidates = uniqueCleanTokens([
Object.keys(SEARCH_TOPIC_ALIASES),
Object.values(SEARCH_TOPIC_ALIASES).flat(),
Object.values(SEARCH_TOPIC_GROUPS).flat(),
]);
return candidates.some((candidate) => {
const normalized = normalizeSearchText(candidate);
return normalized === query || normalized.replace(/\s+/g, "") === queryCompact;
});
}
function fuzzySearchMatch(query, token) {
if (!query || !token || query.length < 2) return false;
if (query.length <= 2 || token.length <= 2) return query === token;
if (token.includes(query)) return true;
if (token.length >= 3 && query.includes(token)) return true;
if (query.length < 3 || token.length < 3) return false;
const threshold = Math.max(query.length, token.length) >= 7 ? 2 : 1;
return Math.abs(query.length - token.length) <= threshold && levenshteinDistance(query, token, threshold) <= threshold;
}
function levenshteinDistance(a, b, maxDistance = Infinity) {
const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
const current = new Array(b.length + 1);
for (let i = 1; i <= a.length; i += 1) {
current[0] = i;
let rowMin = current[0];
for (let j = 1; j <= b.length; j += 1) {
current[j] = Math.min(
previous[j] + 1,
current[j - 1] + 1,
previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
);
rowMin = Math.min(rowMin, current[j]);
}
if (rowMin > maxDistance) return rowMin;
for (let j = 0; j <= b.length; j += 1) previous[j] = current[j];
}
return previous[b.length];
}
function normalizeSearchText(text) {
return String(text || "")
.normalize("NFKC")
.toLowerCase()
.replace(/[《》「」『』"'“”‘’.,,。::;;//\\||·・\-—_()[\]{}]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function renderSearchResults() {
if (!elements.searchResults || !elements.searchInput) return;
const shouldShow = state.search.open && state.search.query.trim() && state.search.results.length;
elements.searchResults.hidden = !shouldShow;
elements.searchInput.setAttribute("aria-expanded", String(Boolean(shouldShow)));
elements.searchInput.setAttribute("aria-activedescendant", shouldShow && state.search.activeIndex >= 0
? searchResultDomId(state.search.activeIndex)
: "");
if (!shouldShow) {
elements.searchResults.replaceChildren();
return;
}
const fragment = document.createDocumentFragment();
state.search.results.forEach((result, index) => {
const button = document.createElement("button");
button.type = "button";
button.id = searchResultDomId(index);
button.className = "search-result";
button.dataset.resultIndex = String(index);
button.setAttribute("role", "option");
button.setAttribute("aria-selected", String(index === state.search.activeIndex));
button.classList.toggle("is-active", index === state.search.activeIndex);
const main = document.createElement("span");
main.className = "search-result__main";
appendHighlightedText(main, result.mainText, state.search.query);
const meta = document.createElement("span");
meta.className = "search-result__meta";
appendHighlightedText(meta, searchResultMetaText(result, state.search.query), state.search.query);
button.append(main, meta);
fragment.append(button);
});
elements.searchResults.replaceChildren(fragment);
syncSearchResultsBounds();
}
function searchResultDomId(index) {
return `search-result-${index}`;
}
function syncSearchResultsBounds() {
if (!elements.searchResults || elements.searchResults.hidden) return;
const rect = elements.searchResults.getBoundingClientRect();
const viewportHeight = window.visualViewport?.height || window.innerHeight;
const availableHeight = Math.max(126, Math.floor(viewportHeight - rect.top - 18));
elements.searchResults.style.setProperty("--search-results-max-height", `${availableHeight}px`);
}
function searchResultMetaText(result, query) {
const metaLead = [
searchAuthors(result.book),
result.book.publisher,
result.book.year,
].filter(Boolean).join("|");
const topics = orderedSearchTopicsForQuery(result.topics, query).slice(0, 4).join("、");
return [metaLead, topics].filter(Boolean).join("|");
}
function orderedSearchTopicsForQuery(topics, query) {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return topics;
const queryCompact = normalizedQuery.replace(/\s+/g, "");
const queryTokens = searchQueryTokens(normalizedQuery);
const scored = topics.map((topic, index) => {
const field = prepareSearchableFields([topic])[0];
const score = field ? scoreSearchField(field, normalizedQuery, queryCompact, queryTokens, "topic") : 0;
return { topic, index, score };
});
scored.sort((a, b) => b.score - a.score || a.index - b.index);
return scored.map((item) => item.topic);
}
function appendHighlightedText(root, text, query) {
const source = String(text || "");
const ranges = highlightedRanges(source, query);
let cursor = 0;
ranges.forEach(([start, end]) => {
if (start > cursor) root.append(document.createTextNode(source.slice(cursor, start)));
const mark = document.createElement("mark");
mark.textContent = source.slice(start, end);
root.append(mark);
cursor = end;
});
if (cursor < source.length) root.append(document.createTextNode(source.slice(cursor)));
}
function highlightedRanges(text, query) {
const source = String(text || "");
const cleanQuery = String(query || "").trim();
if (!source || !cleanQuery) return [];
const sourceLower = source.toLowerCase();
const queryLower = cleanQuery.toLowerCase();
const ranges = [];
let index = sourceLower.indexOf(queryLower);
while (index >= 0) {
ranges.push([index, index + queryLower.length]);
index = sourceLower.indexOf(queryLower, index + queryLower.length);
}
return ranges;
}
function handleSearchKeydown(event) {
if (!state.search.open || !state.search.results.length) {
if (event.key === "Enter") event.preventDefault();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveSearchResult(state.search.activeIndex + 1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveSearchResult(state.search.activeIndex - 1);
} else if (event.key === "Enter") {
event.preventDefault();
activateSearchResult(state.search.activeIndex);
} else if (event.key === "Escape") {
closeSearchResults();
}
}
function setActiveSearchResult(index, options = {}) {
if (!state.search.results.length) return;
const nextIndex = (index + state.search.results.length) % state.search.results.length;
if (nextIndex === state.search.activeIndex) return;
state.search.activeIndex = nextIndex;
renderSearchResults();
const active = elements.searchResults?.querySelector(`#${searchResultDomId(state.search.activeIndex)}`);
if (options.scroll !== false) active?.scrollIntoView({ block: "nearest" });
}
function syncSearchActiveFromPointer(event) {
const option = event.target.closest(".search-result[data-result-index]");
if (!option || !elements.searchResults?.contains(option)) return;
setActiveSearchResult(Number(option.dataset.resultIndex), { scroll: false });
}
function activateSearchResult(index) {
const result = state.search.results[index];
if (!result) return;
openSearchResult(result);
}
function openSearchResult(result) {
const resultIndex = state.search.results.indexOf(result);
trackEvent("search_result_clicked", bookAnalyticsProps(result.city, result.book, searchAnalyticsProps(state.search.query, {
source_view: "search_results",
result_index: resultIndex,
result_count: state.search.results.length,
})));
playUiSound("pageFlick");
state.activeSpecialId = null;
state.activeSpecialStackKey = null;
state.specialStackIndexes.clear();
state.specialStackRenderData.clear();
state.bookCardMode = "ticket";
state.detailOrigin = null;
state.detailReturnTarget = null;
closeSharePreview();
const searchPoint = {
...result.city,
searchContext: true,
};
navigateToSearchResult(searchPoint, result.bookIndex, resultIndex, state.search.results.length);
}
function navigateToSearchResult(searchPoint, bookIndex, resultIndex, resultCount) {
const navigation = state.searchNavigation;
navigation.token += 1;
const token = navigation.token;
if (navigation.closeTimer) {
clearTimeout(navigation.closeTimer);
navigation.closeTimer = null;
}
const hasOpenBookmark = Boolean(state.activePoint && !elements.cityTooltip.hidden);
if (hasOpenBookmark) {
elements.cityTooltip.classList.add("is-search-transitioning-out");
elements.cityTooltip.getBoundingClientRect();
}
const startNavigation = () => {
if (token !== state.searchNavigation.token) return;
navigation.closeTimer = null;
elements.cityTooltip.classList.remove("is-search-transitioning-out");
state.activePoint = null;
state.tooltipPoint = null;
state.bookCardMode = "ticket";
state.detailOrigin = null;
state.detailReturnTarget = null;
applyBookCardModeClass();
elements.cityTooltip.hidden = true;
render();
const [lon, lat] = cityCoordinates(searchPoint);
const showSearchResult = () => {
if (token !== state.searchNavigation.token) return;
state.activePoint = searchPoint;
state.activeBookIndexes.set(searchPoint.id, bookIndex);
trackBookTicketOpened(searchPoint, "search_results", {
result_index: resultIndex,
result_count: resultCount,
});
render();
};
if (Number.isFinite(lon) && Number.isFinite(lat)) {
const targetZoom = Math.max(state.zoom, 1.92);
animateView({
center: tooltipFocusCenterForCoordinate([lon, lat], targetZoom),
zoom: targetZoom,
onComplete: showSearchResult,
});
} else {
showSearchResult();
}
};
if (hasOpenBookmark) {
navigation.closeTimer = window.setTimeout(startNavigation, SEARCH_BOOKMARK_REPLACE_MS);
} else {
startNavigation();
}
}
function closeSearchResults() {
state.search.open = false;
state.search.activeIndex = -1;
renderSearchResults();
}
function savedShelfItems() {
const itemsByBookId = new Map();
mapData.cities.forEach((city) => {
pointBooks(city).forEach((book, bookIndex) => {
if (!book?.bookId || itemsByBookId.has(book.bookId)) return;
itemsByBookId.set(book.bookId, { city, book, bookIndex });
});
});
savedSpecialShelfItems().forEach((item) => {
if (!item?.book?.bookId || itemsByBookId.has(item.book.bookId)) return;
itemsByBookId.set(item.book.bookId, item);
});
return state.savedBookRecords
.map((record) => {
const item = itemsByBookId.get(record.bookId);
return item ? { ...item, savedAt: record.savedAt } : null;
})
.filter(Boolean);
}
function savedSpecialShelfItems() {
const items = [];
mapData.specials.forEach((special) => {
specialBookEntries(special).forEach((entry, bookIndex) => {
const book = entry.book;
if (!book?.bookId || book.isPlaceholder) return;
items.push({
city: specialShelfCity(special, book, entry),
book,
bookIndex,
});
});
});
return items;
}
function specialBookEntries(special) {
const books = special.books.length ? special.books : [curatingPlaceholderBook(special)];
return specialBookGroups(special, books).flatMap((group) => group.entries);
}
function specialShelfCity(special, book, entry) {
const category = specialBookCategory(book, special);
return {
...special.city,
id: `${special.id}--${book.bookId || entry.sourceIndex}`,
books: [book],
specialContext: {
id: special.id,
category,
categoryEn: specialBookCategoryEn(category),
},
};
}
function updateSaveButtons(book) {
const saved = state.savedBooks.has(book.bookId);
elements.cityTooltip.querySelectorAll(".book-ticket[data-book-index]").forEach((ticket) => {
const point = state.activePoint;
const ticketBook = pointBooks(point)[clampBookIndex(point, Number(ticket.dataset.bookIndex))];
updateBookTicketSaveButton(ticket, ticketBook);
});
elements.cityTooltip.querySelectorAll(".book-card__detail-view .book-card__save").forEach((saveButton) => {
const label = saveButton.querySelector(".book-card__action-label");
if (label) {
label.textContent = saved ? "已收藏" : "收藏";
} else {
saveButton.textContent = saved ? "已收藏" : "收藏";
}
saveButton.classList.toggle("is-saved", saved);
});
}
function updateBookTicketSaveButton(ticket, book) {
const saved = Boolean(book?.bookId && state.savedBooks.has(book.bookId));
ticket.querySelectorAll(".book-card__save").forEach((saveButton) => {
const label = saveButton.querySelector(".book-card__action-label");
if (label) {
label.textContent = saved ? "已收藏" : "收藏";
} else {
saveButton.textContent = saved ? "已收藏" : "收藏";
}
saveButton.classList.toggle("is-saved", saved);
});
}
function toggleActiveBookSave() {
const book = activeBook(state.activePoint);
if (!book?.bookId) return;
const willSave = !state.savedBooks.has(book.bookId);
if (state.savedBooks.has(book.bookId)) {
state.savedBookRecords = removeSavedBook(state.savedBookRecords, book.bookId);
} else {
state.savedBookRecords = upsertSavedBook(state.savedBookRecords, book.bookId);
}
playUiSound(willSave ? "coinCollect" : "paperClose");
syncSavedBookSet();
persistSavedBooks();
updateSaveButtons(book);
renderBookshelf();
trackEvent(willSave ? "book_saved_to_shelf" : "book_removed_from_shelf", bookAnalyticsProps(state.activePoint, book, {
source_view: state.bookCardMode === "detail" ? "book_detail" : "book_ticket",
shelf_count_after: state.savedBooks.size,
}));
}
function enterBookDetailMode() {
const point = state.activePoint;
const book = activeBook(point);
if (point && book) {
trackEvent("book_detail_opened", bookAnalyticsProps(point, book, {
source_view: "book_ticket",
}));
}
state.detailReturnTarget = null;
state.detailOrigin = captureDetailOrigin();
state.bookCardMode = "detail";
applyBookCardModeClass();
updateActiveDetailAtFixedAnchor();
warmShareHalftoneQueue();
}
function setBookCardMode(mode) {
state.bookCardMode = mode;
applyBookCardModeClass();
syncTooltip(createProjection());
}
function applyBookCardModeClass() {
elements.cityTooltip.classList.toggle("is-ticket", state.bookCardMode === "ticket");
elements.cityTooltip.classList.toggle("is-detail", state.bookCardMode === "detail");
elements.cityTooltip.classList.toggle("is-from-bookshelf-detail", state.bookCardMode === "detail" && state.detailReturnTarget === "bookshelf");
if (state.bookCardMode === "detail") {
elements.cityTooltip.classList.remove("has-book-stack");
}
}
function openSharePreview() {
const point = state.activePoint;
const book = activeBook(point);
if (!point || !book || !elements.sharePreview) return;
trackEvent("share_opened", bookAnalyticsProps(point, book, {
source_view: state.bookCardMode === "detail" ? "book_detail" : "book_ticket",
}));
warmShareHalftoneQueue({ immediateActive: true });
const renderPromise = renderSharePreviewBook(point, book);
syncSharePreviewPager();
return renderPromise;
}
function renderSharePreviewBook(point, book) {
if (!point || !book || !elements.sharePreview) return;
const preview = elements.sharePreview;
const token = `${bookIdentity(book)}:${(Number(preview.dataset.renderToken) || 0) + 1}`;
preview.dataset.renderToken = token;
preview.classList.remove("has-share-controls");
preview.classList.add("share-controls-muted");
setSharePreviewLoading(true);
const stage = createSharePreviewStage(preview);
const cardCity = currentBookCardCity(point, book);
const prepare = async () => {
let coverUrl = "";
let stagedHalftoneReady = false;
let stagedHalftonePromise = null;
let stagedHalftoneRequestId = "";
removeSharePreviewBackgroundProperty(stage, "--share-cover-image");
removeSharePreviewBackgroundProperty(stage, "--share-halftone-image");
delete stage.dataset.halftoneRequest;
delete stage.dataset.halftoneStatus;
delete stage.dataset.halftoneObjectUrl;
fillBookCard(stage, cardCity, book);
stage.dataset.bookId = bookIdentity(book);
if (book.coverUrl) {
coverUrl = shareHalftoneCoverUrl(book);
setSharePreviewBackgroundProperty(stage, "--share-cover-image", `url("${coverUrl}")`);
stagedHalftoneRequestId = shareHalftoneRequestId(book, coverUrl);
stagedHalftonePromise = applyShareHalftoneBackground(book, coverUrl, stage, {
requestId: stagedHalftoneRequestId,
priority: SHARE_HALFTONE_PRIORITY.visible,
});
}
await waitForSharePreviewImages(stage);
if (document.fonts?.ready) await document.fonts.ready;
await nextAnimationFrame();
await nextAnimationFrame();
fitBookMetaLine(stage);
fitShareOriginalTitle(stage);
updateTicketCutPaths(stage);
if (stagedHalftonePromise) {
stagedHalftoneReady = await waitForInitialShareHalftone(
stagedHalftonePromise,
stage,
stagedHalftoneRequestId,
SHARE_HALFTONE_REVEAL_WAIT_MS,
);
}
if (preview.dataset.renderToken !== token) return false;
commitSharePreviewStage(preview, stage);
preview.hidden = false;
if (coverUrl && !stagedHalftoneReady) void applyShareHalftoneBackground(book, coverUrl, preview);
return true;
};
return prepare()
.catch((error) => {
console.warn("Share preview preparation failed", error);
if (preview.dataset.renderToken === token) preview.hidden = false;
return false;
})
.finally(() => {
stage.remove();
if (preview.dataset.renderToken === token) setSharePreviewLoading(false);
});
}
function createSharePreviewStage(preview) {
const stage = preview.cloneNode(true);
stage.removeAttribute("id");
stage.hidden = false;
stage.classList.add("share-preview--staging");
stage.setAttribute("aria-hidden", "true");
document.body.append(stage);
return stage;
}
function waitForSharePreviewImages(root) {
const images = [...root.querySelectorAll(".share-ticket__cover img")].filter((image) => !image.hidden);
return Promise.all(images.map(async (image) => {
if (image.complete && image.naturalWidth) {
await image.decode?.().catch(() => {});
return;
}
return new Promise((resolve) => {
image.addEventListener("load", async () => {
await image.decode?.().catch(() => {});
resolve();
}, { once: true });
image.addEventListener("error", resolve, { once: true });
});
}));
}
function commitSharePreviewStage(preview, stage) {
revokeShareHalftoneObjectUrl(preview);
preview.innerHTML = stage.innerHTML;
preview.classList.toggle("is-special-book-card", stage.classList.contains("is-special-book-card"));
preview.classList.remove("has-share-controls");
preview.classList.add("share-controls-muted");
preview.classList.add("is-revealing");
const revealToken = preview.dataset.renderToken;
preview.dataset.revealToken = revealToken;
window.setTimeout(() => {
if (preview.dataset.revealToken === revealToken) preview.classList.remove("is-revealing");
}, 240);
preview.dataset.bookId = stage.dataset.bookId || "";
["halftoneRequest", "halftoneStatus"].forEach((key) => {
const value = stage.dataset[key];
if (value) preview.dataset[key] = value;
else delete preview.dataset[key];
});
const objectUrl = stage.dataset.halftoneObjectUrl;
if (objectUrl) preview.dataset.halftoneObjectUrl = objectUrl;
["--share-cover-image", "--share-halftone-image"].forEach((property) => {
const value = stage.style.getPropertyValue(property);
if (value) setSharePreviewBackgroundProperty(preview, property, value);
else removeSharePreviewBackgroundProperty(preview, property);
});
}
function sharePreviewCanvasElement(preview) {
return preview?.classList?.contains("share-preview__canvas")
? preview
: preview?.querySelector?.(".share-preview__canvas") || null;
}
function setSharePreviewBackgroundProperty(preview, property, value) {
preview?.style?.setProperty(property, value);
const canvas = sharePreviewCanvasElement(preview);
canvas?.style.setProperty(property, value);
if (property === "--share-halftone-image") {
applySharePreviewCanvasHalftoneBackground(canvas, value);
}
}
function removeSharePreviewBackgroundProperty(preview, property) {
preview?.style?.removeProperty(property);
const canvas = sharePreviewCanvasElement(preview);
canvas?.style.removeProperty(property);
if (property === "--share-halftone-image") {
clearSharePreviewCanvasHalftoneBackground(canvas);
}
}
function applySharePreviewCanvasHalftoneBackground(canvas, imageValue) {
if (!canvas || !imageValue) return;
canvas.style.setProperty(
"background-image",
`${imageValue}, linear-gradient(180deg, rgba(255, 253, 247, 0.34), rgba(235, 248, 244, 0.18))`,
"important",
);
canvas.style.setProperty("background-position", "center, center", "important");
canvas.style.setProperty("background-size", "cover, auto", "important");
}
function clearSharePreviewCanvasHalftoneBackground(canvas) {
if (!canvas) return;
canvas.style.removeProperty("background-image");
canvas.style.removeProperty("background-position");
canvas.style.removeProperty("background-size");
}
function waitForSharePreviewReveal() {
const preview = elements.sharePreview;
if (!preview?.classList.contains("is-revealing")) return Promise.resolve();
return new Promise((resolve) => {
window.setTimeout(resolve, 240);
});
}
function setSharePreviewLoading(isLoading) {
const preview = elements.sharePreview;
if (!preview) return;
preview.classList.toggle("is-loading", isLoading);
preview.setAttribute("aria-busy", String(isLoading));
preview.querySelectorAll(".share-preview__page, .share-preview__save").forEach((button) => {
button.disabled = isLoading;
});
}
function revealSharePreviewControls() {
const preview = elements.sharePreview;
if (!preview) return;
if (state.shareControlsHideTimer) {
window.clearTimeout(state.shareControlsHideTimer);
state.shareControlsHideTimer = null;
}
preview.classList.remove("share-controls-muted");
preview.classList.add("has-share-controls");
}
function scheduleSharePreviewControlsHide() {
const preview = elements.sharePreview;
if (!preview) return;
if (state.shareControlsHideTimer) window.clearTimeout(state.shareControlsHideTimer);
state.shareControlsHideTimer = window.setTimeout(() => {
preview.classList.remove("has-share-controls");
state.shareControlsHideTimer = null;
}, 180);
}
function sharePreviewItems() {
const point = state.activePoint;
if (!point) return [];
if (point?.specialContext) return specialSharePreviewItems(point);
return globalSharePreviewItems();
}
function globalSharePreviewItems() {
return cityExplorePoints().flatMap((city) => pointBooks(city).map((book, bookIndex) => ({ city, book, bookIndex })));
}
function specialSharePreviewItems(point) {
const books = pointBooks(point);
if (books.length) {
return books.map((book, bookIndex) => ({ city: point, book, bookIndex }));
}
const special = mapData.specials.find((item) => item.id === point.specialContext.id) || activeSpecial();
const entries = specialSharePreviewEntries(special);
return entries.map((entry, bookIndex) => ({ city: point, book: entry.book, bookIndex }));
}
function specialSharePreviewEntries(special) {
if (!special) return [];
const books = special.books.length ? special.books.map(specialBookPayload) : [curatingPlaceholderBook(special)];
const entries = spreadSpecialBookEntries(special, books).filter((entry) => !entry.book?.isPlaceholder);
return special.cityMap ? orderCityMapBookEntries(entries, special).slice(0, SPECIAL_BOOK_OFFSETS.length) : entries;
}
function activeSharePreviewItemIndex(items) {
const activeId = state.activePoint?.id;
const activeIndex = activeBookIndex(state.activePoint);
const index = items.findIndex((item) => item.city.id === activeId && item.bookIndex === activeIndex);
return index >= 0 ? index : 0;
}
function warmShareHalftoneQueue(options = {}) {
const items = sharePreviewItems();
if (!items.length) return;
const activeIndex = activeSharePreviewItemIndex(items);
const offsets = [0];
for (let distance = 1; distance <= SHARE_HALFTONE_PREWARM_NEIGHBORS; distance += 1) {
offsets.push(distance, -distance);
}
const warmed = new Set();
offsets.forEach((offset) => {
const index = (activeIndex + offset + items.length) % items.length;
if (warmed.has(index)) return;
warmed.add(index);
warmShareHalftoneForBook(items[index]?.book, {
immediate: offset === 0 && options.immediateActive,
});
});
}
function syncSharePreviewPager() {
if (!elements.sharePreview) return;
const canPage = sharePreviewItems().length > 1;
elements.sharePreview.querySelectorAll(".share-preview__page").forEach((button) => {
button.hidden = !canPage;
button.tabIndex = canPage ? 0 : -1;
button.setAttribute("aria-hidden", String(!canPage));
});
}
function pageSharePreview(delta) {
const items = sharePreviewItems();
if (items.length <= 1) return;
const previousIndex = activeSharePreviewItemIndex(items);
const previousItem = items[previousIndex];
const nextIndex = (previousIndex + delta + items.length) % items.length;
const item = items[nextIndex];
warmShareHalftoneForBook(item.book, { immediate: true });
const usesSpecialBookmarkLayer = pointUsesSpecialBookmarkLayer(item.city);
state.activePoint = item.city;
state.activeBookIndexes.set(item.city.id, item.bookIndex);
warmShareHalftoneQueue({ immediateActive: true });
state.bookCardMode = usesSpecialBookmarkLayer ? "detail" : "ticket";
state.detailOrigin = null;
state.detailReturnTarget = null;
syncCityMarkerActiveClasses();
if (usesSpecialBookmarkLayer) {
updateActiveDetailAtFixedAnchor();
} else {
syncTooltip(createProjection(), { skipTooltipContent: false });
}
renderSharePreviewBook(item.city, item.book);
syncSharePreviewPager();
trackEvent("share_preview_paged", bookAnalyticsProps(item.city, item.book, {
source_view: "share_preview",
direction: delta > 0 ? "next" : "previous",
share_preview_index: nextIndex,
share_preview_count: items.length,
previous_book_id: bookIdentity(previousItem?.book),
previous_city_id: previousItem?.city?.cityId || previousItem?.city?.id || "",
is_special_queue: Boolean(item.city?.specialContext),
}));
playUiSound("pageFlick");
}
async function maybeOpenShareSnapshotFromUrl() {
const params = new URLSearchParams(window.location.search);
const bookId = params.get("shareTicketBookId");
if (!bookId) return;
document.documentElement.dataset.shareSnapshotReady = "loading";
try {
const theme = params.get("theme");
if (theme === "light" || theme === "dark") {
state.theme = theme;
applyTheme({ syncMusic: false });
}
await openShareSnapshotBook(bookId, { renderMap: true });
} catch (error) {
console.error("Failed to render share snapshot", error);
document.documentElement.dataset.shareSnapshotError = error.message || "Failed to render share snapshot";
document.documentElement.dataset.shareSnapshotReady = "failed";
}
}
async function openShareSnapshotBook(bookId, options = {}) {
const item = findShareSnapshotBook(bookId);
if (!item) throw new Error(`Book not found for share snapshot: ${bookId}`);
state.activePoint = item.city;
state.bookCardMode = "ticket";
state.activeBookIndexes.set(item.city.id, item.bookIndex);
if (options.renderMap) render();
await openSharePreview();
await waitForShareSnapshotRender(10000, { halftoneMaxWait: options.halftoneMaxWait });
await nextAnimationFrame();
await nextAnimationFrame();
await waitForSharePreviewReveal();
document.documentElement.dataset.shareSnapshotReady = "ready";
}
function findShareSnapshotBook(bookId) {
for (const city of mapData.cities) {
const books = pointBooks(city);
const bookIndex = books.findIndex((book) => (book.bookId || book.id) === bookId);
if (bookIndex >= 0) return { city, book: books[bookIndex], bookIndex };
}
const specialItem = savedSpecialShelfItems().find((item) => (item.book?.bookId || item.book?.id) === bookId);
return specialItem ? { ...specialItem, bookIndex: 0 } : null;
}
async function waitForShareSnapshotRender(timeout = 10000, options = {}) {
const startedAt = performance.now();
const halftoneMaxWait = Number.isFinite(options.halftoneMaxWait) ? options.halftoneMaxWait : timeout;
while (performance.now() - startedAt < timeout) {
const elapsed = performance.now() - startedAt;
const canvas = elements.sharePreview?.querySelector(".share-preview__canvas");
const halftoneReady = elements.sharePreview?.dataset.halftoneStatus !== "loading" || elapsed >= halftoneMaxWait;
const rect = canvas?.getBoundingClientRect();
if (canvas && !elements.sharePreview.hidden && halftoneReady && rect.width > 0 && rect.height > 0) {
await waitForSnapshotAssets(canvas);
return;
}
await nextAnimationFrame();
}
throw new Error("Timed out rendering share snapshot");
}
function nextAnimationFrame() {
return new Promise((resolve) => requestAnimationFrame(resolve));
}
function shareHalftoneRequestId(book, coverUrl) {
return `${bookIdentity(book)}:${coverUrl}`;
}
function shareHalftoneCoverUrl(book) {
return book?.coverUrl ? new URL(book.coverUrl, window.location.href).href : "";
}
function warmShareHalftoneForBook(book, options = {}) {
const coverUrl = shareHalftoneCoverUrl(book);
if (!coverUrl) return null;
const priority = options.immediate ? SHARE_HALFTONE_PRIORITY.immediate : SHARE_HALFTONE_PRIORITY.prewarm;
if (options.immediate) cancelLowerPriorityShareHalftoneWork(priority, shareHalftoneRequestId(book, coverUrl));
const run = () => {
const entry = shareHalftoneCacheEntry(book, coverUrl, {
priority,
});
entry.promise.catch(() => {});
return entry.promise;
};
if (options.immediate) return run();
if (typeof window.requestIdleCallback === "function") {
window.requestIdleCallback(run, { timeout: SHARE_HALFTONE_PREWARM_TIMEOUT_MS });
} else {
window.setTimeout(run, 120);
}
return null;
}
function shareHalftoneCacheEntry(book, coverUrl, options = {}) {
const requestId = shareHalftoneRequestId(book, coverUrl);
const priority = options.priority || SHARE_HALFTONE_PRIORITY.prewarm;
const existing = shareHalftoneCache.get(requestId);
if (existing && existing.status !== "failed") {
existing.lastUsedAt = performance.now();
promoteShareHalftoneEntry(existing, priority);
return existing;
}
if (existing?.status === "failed") shareHalftoneCache.delete(requestId);
const entry = {
requestId,
status: "loading",
blob: null,
error: null,
lastUsedAt: performance.now(),
priority,
promise: null,
};
entry.promise = loadCanvasSafeImage(coverUrl)
.then(async (image) => {
entry.status = "queued";
try {
return await enqueueShareHalftoneRender(entry, (job) => createCoverHalftoneBlob(image, book, job));
} finally {
releaseCanvasSafeImage(image);
}
})
.then((blob) => {
entry.blob = blob;
entry.status = "ready";
entry.lastUsedAt = performance.now();
return entry.blob;
})
.catch((error) => {
entry.status = error?.name === "AbortError" ? "cancelled" : "failed";
entry.error = error;
if (entry.status === "cancelled") shareHalftoneCache.delete(requestId);
throw error;
});
shareHalftoneCache.set(requestId, entry);
pruneShareHalftoneCache();
return entry;
}
function pruneShareHalftoneCache() {
if (shareHalftoneCache.size <= SHARE_HALFTONE_CACHE_LIMIT) return;
const entries = [...shareHalftoneCache.values()]
.filter((entry) => entry.status !== "queued" && entry.status !== "loading")
.sort((a, b) => (a.status === "loading") - (b.status === "loading") || a.lastUsedAt - b.lastUsedAt);
while (shareHalftoneCache.size > SHARE_HALFTONE_CACHE_LIMIT && entries.length) {
shareHalftoneCache.delete(entries.shift().requestId);
}
}
function promoteShareHalftoneEntry(entry, priority) {
entry.priority = Math.max(entry.priority || 0, priority || 0);
if (entry.status === "queued") drainShareHalftoneRenderQueue();
}
function enqueueShareHalftoneRender(entry, task) {
return new Promise((resolve, reject) => {
shareHalftoneRenderQueue.push({ entry, task, resolve, reject, cancelled: false });
drainShareHalftoneRenderQueue();
});
}
function cancelLowerPriorityShareHalftoneWork(priority, keepRequestId = "") {
for (let index = shareHalftoneRenderQueue.length - 1; index >= 0; index -= 1) {
const job = shareHalftoneRenderQueue[index];
if (!job || job.entry.requestId === keepRequestId || (job.entry.priority || 0) >= priority) continue;
shareHalftoneRenderQueue.splice(index, 1);
cancelShareHalftoneJob(job);
}
shareHalftoneActiveJobs.forEach((job) => {
if (!job || job.entry.requestId === keepRequestId || (job.entry.priority || 0) >= priority) return;
job.cancelled = true;
});
}
function cancelShareHalftoneJob(job) {
job.cancelled = true;
job.entry.status = "cancelled";
shareHalftoneCache.delete(job.entry.requestId);
job.reject(shareHalftoneCancelledError());
}
function shareHalftoneCancelledError() {
const error = new Error("Share halftone render superseded");
error.name = "AbortError";
return error;
}
function drainShareHalftoneRenderQueue() {
if (shareHalftoneActiveRenders >= SHARE_HALFTONE_RENDER_CONCURRENCY) return;
if (!shareHalftoneRenderQueue.length) return;
shareHalftoneRenderQueue.sort((a, b) => {
const priorityDiff = (b.entry.priority || 0) - (a.entry.priority || 0);
return priorityDiff || b.entry.lastUsedAt - a.entry.lastUsedAt;
});
const job = shareHalftoneRenderQueue.shift();
if (!job) return;
shareHalftoneActiveRenders += 1;
shareHalftoneActiveJobs.add(job);
job.entry.status = "loading";
Promise.resolve()
.then(() => job.task(job))
.then(job.resolve, job.reject)
.finally(() => {
shareHalftoneActiveJobs.delete(job);
shareHalftoneActiveRenders -= 1;
drainShareHalftoneRenderQueue();
});
}
function waitForInitialShareHalftone(promise, preview, requestId, maxWait) {
if (!Number.isFinite(maxWait) || maxWait <= 0) return promise.then(Boolean, () => false);
return new Promise((resolve) => {
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
window.clearTimeout(timer);
resolve(Boolean(value));
};
const timer = window.setTimeout(() => {
if (preview?.dataset.halftoneRequest === requestId) {
delete preview.dataset.halftoneRequest;
preview.dataset.halftoneStatus = "deferred";
}
finish(false);
}, maxWait);
promise.then(finish, () => finish(false));
});
}
async function applyShareHalftoneBackground(book, coverUrl, preview = elements.sharePreview, options = {}) {
if (!preview) return;
const requestId = options.requestId || shareHalftoneRequestId(book, coverUrl);
preview.dataset.halftoneRequest = requestId;
preview.dataset.halftoneStatus = "loading";
try {
const entry = shareHalftoneCacheEntry(book, coverUrl, {
priority: options.priority || SHARE_HALFTONE_PRIORITY.visible,
});
const blob = await entry.promise;
if (preview.dataset.halftoneRequest !== requestId || !preview.isConnected) return false;
const objectUrl = shareHalftoneObjectUrl(blob);
if (preview.dataset.halftoneRequest !== requestId || !preview.isConnected) {
URL.revokeObjectURL(objectUrl);
return false;
}
revokeShareHalftoneObjectUrl(preview);
preview.dataset.halftoneObjectUrl = objectUrl;
setSharePreviewBackgroundProperty(preview, "--share-halftone-image", `url("${objectUrl}")`);
removeSharePreviewBackgroundProperty(preview, "--share-cover-image");
preview.dataset.halftoneStatus = "ready";
return true;
} catch (error) {
console.warn("Share halftone background failed", error);
if (preview.dataset.halftoneRequest !== requestId) return false;
if (error?.name === "AbortError") return false;
if (!options.retried) {
shareHalftoneCache.delete(requestId);
return applyShareHalftoneBackground(book, coverUrl, preview, {
...options,
requestId,
retried: true,
});
}
revokeShareHalftoneObjectUrl(preview);
removeSharePreviewBackgroundProperty(preview, "--share-halftone-image");
preview.dataset.halftoneStatus = "failed";
return false;
}
}
function shareHalftoneObjectUrl(blob) {
if (blob instanceof Blob) return URL.createObjectURL(blob);
const dataUrl = String(blob || "");
const [header, payload = ""] = dataUrl.split(",");
const mime = header.match(/^data:([^;]+)/)?.[1] || "image/png";
const binary = atob(payload);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return URL.createObjectURL(new Blob([bytes], { type: mime }));
}
function revokeShareHalftoneObjectUrl(preview) {
const objectUrl = preview?.dataset.halftoneObjectUrl;
if (!objectUrl) return;
URL.revokeObjectURL(objectUrl);
delete preview.dataset.halftoneObjectUrl;
}
async function loadCanvasSafeImage(src, timeout = 8000) {
let directError = null;
try {
const image = await loadCanvasImageElement(src, timeout, { crossOrigin: "anonymous" });
assertCanvasReadableImage(image, src);
return image;
} catch (error) {
directError = error;
}
try {
const image = await loadCanvasImageFromFetchedBlob(src, timeout);
assertCanvasReadableImage(image, src);
return image;
} catch (error) {
throw error || directError;
}
}
function loadCanvasImageElement(src, timeout = 8000, options = {}) {
return new Promise((resolve, reject) => {
const image = new Image();
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
callback(value);
};
const timer = setTimeout(() => {
if (image.complete && image.naturalWidth) {
finish(resolve, image);
return;
}
finish(reject, new Error(`Timed out loading share cover: ${src}`));
}, timeout);
image.onload = () => finish(resolve, image);
image.onerror = () => finish(reject, new Error(`Failed to load share cover: ${src}`));
if (options.crossOrigin) image.crossOrigin = options.crossOrigin;
image.src = src;
if (image.complete && image.naturalWidth) {
finish(resolve, image);
return;
}
image.decode?.().then(
() => finish(resolve, image),
() => {
if (image.complete && image.naturalWidth) finish(resolve, image);
},
);
});
}
async function loadCanvasImageFromFetchedBlob(src, timeout = 8000) {
const controller = typeof AbortController === "function" ? new AbortController() : null;
const timer = controller ? setTimeout(() => controller.abort(), timeout) : null;
let objectUrl = "";
try {
const response = await fetch(src, {
cache: "force-cache",
credentials: "same-origin",
signal: controller?.signal,
});
if (!response.ok) throw new Error(`Failed to fetch share cover blob: ${src}`);
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
const image = await loadCanvasImageElement(objectUrl, timeout);
image.__shareHalftoneObjectUrl = objectUrl;
objectUrl = "";
return image;
} finally {
if (timer) clearTimeout(timer);
if (objectUrl) URL.revokeObjectURL(objectUrl);
}
}
function assertCanvasReadableImage(image, src) {
const width = Math.max(1, Math.min(2, image.naturalWidth || image.width || 1));
const height = Math.max(1, Math.min(2, image.naturalHeight || image.height || 1));
const probeCanvas = document.createElement("canvas");
const probeContext = probeCanvas.getContext("2d", { willReadFrequently: true });
probeCanvas.width = width;
probeCanvas.height = height;
probeContext.drawImage(image, 0, 0, width, height);
try {
probeContext.getImageData(0, 0, 1, 1);
} catch (error) {
throw new Error(`Share cover is not canvas-readable: ${src}`, { cause: error });
}
}
function releaseCanvasSafeImage(image) {
const objectUrl = image?.__shareHalftoneObjectUrl;
if (!objectUrl) return;
URL.revokeObjectURL(objectUrl);
delete image.__shareHalftoneObjectUrl;
}
function closeSharePreview(options = {}) {
if (!elements.sharePreview) return;
const wasOpen = !elements.sharePreview.hidden;
elements.sharePreview.dataset.renderToken = String((Number(elements.sharePreview.dataset.renderToken) || 0) + 1);
elements.sharePreview.classList.remove("has-share-controls");
elements.sharePreview.classList.remove("share-controls-muted");
if (state.shareControlsHideTimer) {
window.clearTimeout(state.shareControlsHideTimer);
state.shareControlsHideTimer = null;
}
elements.sharePreview.classList.remove("is-revealing");
setSharePreviewLoading(false);
const previewBookId = elements.sharePreview.dataset.bookId || "";
revokeShareHalftoneObjectUrl(elements.sharePreview);
removeSharePreviewBackgroundProperty(elements.sharePreview, "--share-cover-image");
removeSharePreviewBackgroundProperty(elements.sharePreview, "--share-halftone-image");
delete elements.sharePreview.dataset.halftoneRequest;
delete elements.sharePreview.dataset.halftoneStatus;
elements.sharePreview.hidden = true;
if (wasOpen) {
const point = state.activePoint;
const book = activeBook(point);
const props = point && book
? bookAnalyticsProps(point, book, { source_view: "share_preview" })
: { source_view: "share_preview", book_id: previewBookId };
trackEvent("share_preview_closed", {
...props,
reason: options.reason || "implicit",
});
}
}
async function saveShareTicketImage() {
const point = state.activePoint;
const book = activeBook(point);
if (!point || !book) return;
const shareCanvas = elements.sharePreview?.querySelector(".share-preview__canvas");
if (!shareCanvas) return;
let blob = null;
const halftoneReady = await waitForShareHalftoneForSave(book);
if (!halftoneReady) {
const error = new Error("Share halftone background is not ready for export");
trackShareRenderFailed(point, book, "halftone_not_ready");
throw error;
}
if (supportsServerShareSnapshot()) {
blob = await requestRenderedShareSnapshot(book).catch(() => null);
}
if (!blob) {
try {
await waitForSharePreviewReveal();
await waitForSnapshotAssets(shareCanvas);
blob = await snapshotShareCanvasElement(shareCanvas, book);
} catch (error) {
trackShareRenderFailed(point, book, error.message || "snapshot_failed");
throw error;
}
}
if (!blob) {
trackShareRenderFailed(point, book, "empty_blob");
return;
}
downloadBlob(blob, `${slugify(fullBookTitle(book, point) || "world-citybook")}-藏书票.png`);
playUiSound("saveStamp");
trackEvent("share_image_saved", bookAnalyticsProps(point, book, {
source_view: "share_preview",
}));
}
async function waitForShareHalftoneForSave(book, timeout = 30000) {
const preview = elements.sharePreview;
const canvas = preview?.querySelector(".share-preview__canvas");
if (!preview || !canvas) return false;
const hasReadyBackground = () => (
preview.dataset.halftoneStatus === "ready"
&& Boolean(canvas.style.getPropertyValue("--share-halftone-image") || preview.style.getPropertyValue("--share-halftone-image"))
);
if (hasReadyBackground()) return true;
const coverUrl = shareHalftoneCoverUrl(book);
if (!coverUrl) return false;
const requestId = shareHalftoneRequestId(book, coverUrl);
if (preview.dataset.halftoneStatus === "failed") {
shareHalftoneCache.delete(requestId);
delete preview.dataset.halftoneRequest;
delete preview.dataset.halftoneStatus;
removeSharePreviewBackgroundProperty(preview, "--share-halftone-image");
setSharePreviewBackgroundProperty(preview, "--share-cover-image", `url("${coverUrl}")`);
}
const renderPromise = applyShareHalftoneBackground(book, coverUrl, preview, {
requestId,
priority: SHARE_HALFTONE_PRIORITY.visible,
});
const resolved = await Promise.race([
renderPromise,
new Promise((resolve) => window.setTimeout(() => resolve(false), timeout)),
]);
return Boolean(resolved || hasReadyBackground());
}
async function requestRenderedShareSnapshot(book) {
const bookId = bookIdentity(book);
if (!bookId || !supportsServerShareSnapshot()) return null;
const response = await fetch("/api/share-ticket-snapshot", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bookId,
theme: state.theme,
scale: Math.min(3, Math.max(2, window.devicePixelRatio || 2)),
}),
});
if (!response.ok) throw new Error(await response.text());
return response.blob();
}
function supportsServerShareSnapshot() {
if (window.location.protocol === "file:") return false;
return ["localhost", "127.0.0.1"].includes(window.location.hostname);
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
async function waitForSnapshotAssets(root) {
const images = [...root.querySelectorAll("img")];
await Promise.all(images.map(async (image) => {
if (!image.currentSrc && !image.src) return;
if (!image.complete || image.naturalWidth === 0) {
await image.decode?.().catch(() => {});
}
}));
await document.fonts?.ready?.catch?.(() => {});
}
async function snapshotShareCanvasElement(element, book) {
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
const width = Math.ceil(rect.width);
const height = Math.ceil(rect.height);
const scale = Math.min(3, Math.max(2, window.devicePixelRatio || 2));
const svg = await buildShareSnapshotDocument(element, width, height);
const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
const canvas = document.createElement("canvas");
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const context = canvas.getContext("2d");
if (!context) return null;
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.scale(scale, scale);
context.clearRect(0, 0, width, height);
await paintShareSnapshotBackground(context, element, width, height);
const image = await loadImage(svgUrl);
context.drawImage(image, 0, 0, width, height);
return new Promise((resolve) => canvas.toBlob(resolve, "image/png", 0.96));
}
async function paintShareSnapshotBackground(context, element, width, height) {
const style = getComputedStyle(element);
const halftoneUrl = cssUrlFromValue(style.getPropertyValue("--share-halftone-image"))
|| cssUrlFromValue(style.backgroundImage);
const coverUrl = cssUrlFromValue(style.getPropertyValue("--share-cover-image"));
const imageUrl = halftoneUrl || coverUrl;
context.fillStyle = "#f8f1df";
context.fillRect(0, 0, width, height);
if (!imageUrl) return;
const dataUrl = await snapshotResourceDataUrl(imageUrl).catch(() => null);
if (!dataUrl) return;
const image = await loadImage(dataUrl).catch(() => null);
if (!image) return;
drawCoverImage(context, image, 0, 0, width, height);
}
function cssUrlFromValue(value) {
const match = String(value || "").match(/url\((["']?)(.*?)\1\)/);
const url = match?.[2]?.trim();
return url && url !== "none" ? url : "";
}
async function buildShareSnapshotDocument(element, width, height) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
svg.setAttribute("width", String(width));
svg.setAttribute("height", String(height));
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
const foreignObject = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
foreignObject.setAttribute("width", "100%");
foreignObject.setAttribute("height", "100%");
const wrapper = document.createElement("div");
wrapper.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
wrapper.setAttribute("data-share-snapshot", "");
wrapper.style.width = `${width}px`;
wrapper.style.height = `${height}px`;
const style = document.createElement("style");
style.textContent = await snapshotShareStyles();
const clone = element.cloneNode(true);
await prepareShareSnapshotClone(clone, element);
wrapper.append(style, clone);
foreignObject.append(wrapper);
svg.append(foreignObject);
return svg;
}
async function snapshotShareStyles() {
const cssText = [...document.styleSheets].map((sheet) => {
try {
return [...sheet.cssRules].map((rule) => absolutizeCssUrls(rule.cssText)).join("\n");
} catch (error) {
return "";
}
}).join("\n");
const snapshotCss = `${cssText}
[data-share-snapshot] {
width: 100%;
height: 100%;
}
[data-share-snapshot] .share-preview__canvas {
width: 100% !important;
height: 100% !important;
margin: 0 !important;
background: transparent !important;
}
[data-share-snapshot] .share-ticket-shell {
filter:
drop-shadow(0 0 18px rgba(45, 48, 43, 0.15))
drop-shadow(0 12px 32px rgba(45, 48, 43, 0.1)) !important;
}
[data-share-snapshot] .share-preview__canvas .share-ticket {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.58) !important;
filter: none !important;
}
[data-share-snapshot] .share-preview__canvas::before {
content: none !important;
display: none !important;
}
[data-share-snapshot] .share-preview__canvas::after {
content: none !important;
display: none !important;
}
[data-share-snapshot] .share-preview__topbar,
[data-share-snapshot] .share-preview__actions {
display: none !important;
}`;
return inlineSnapshotCssUrls(snapshotCss);
}
async function prepareShareSnapshotClone(clone, element) {
clone.removeAttribute("id");
clone.querySelectorAll("[id]").forEach((child) => child.removeAttribute("id"));
copyShareSnapshotComputedStyles(clone, element);
await copyShareSnapshotCustomProperties(clone, element);
await inlineShareSnapshotStyleAttributeUrls(clone);
await Promise.all([...clone.querySelectorAll("img")].map(async (image) => {
const src = image.currentSrc || image.src || image.getAttribute("src");
if (!src) return;
image.setAttribute("src", await snapshotResourceDataUrl(src));
image.removeAttribute("srcset");
image.removeAttribute("sizes");
}));
}
async function inlineShareSnapshotStyleAttributeUrls(root) {
const styledElements = [root, ...root.querySelectorAll("[style]")];
await Promise.all(styledElements.map(async (node) => {
const styleText = node.getAttribute("style");
if (!styleText || !styleText.includes("url(")) return;
node.setAttribute("style", await inlineSnapshotCssUrls(styleText));
}));
}
function copyShareSnapshotComputedStyles(clone, element) {
const sourceElements = [element, ...element.querySelectorAll("*")];
const cloneElements = [clone, ...clone.querySelectorAll("*")];
sourceElements.forEach((sourceElement, index) => {
const cloneElement = cloneElements[index];
if (!cloneElement) return;
const computed = getComputedStyle(sourceElement);
for (const property of computed) {
cloneElement.style.setProperty(property, computed.getPropertyValue(property), computed.getPropertyPriority(property));
}
});
}
async function copyShareSnapshotCustomProperties(clone, element) {
const style = getComputedStyle(element);
await Promise.all(["--share-cover-image", "--share-halftone-image"].map(async (name) => {
const value = style.getPropertyValue(name).trim();
if (value) clone.style.setProperty(name, await inlineSnapshotCssUrls(value));
}));
}
function absolutizeCssUrls(cssText) {
return String(cssText || "").replace(/url\((["']?)(.*?)\1\)/g, (_match, quote, rawUrl) => {
const url = rawUrl.trim();
if (!url || /^(?:data:|blob:|https?:|#)/i.test(url)) return `url(${quote}${url}${quote})`;
return `url("${absoluteSnapshotUrl(url)}")`;
});
}
function absoluteSnapshotUrl(url) {
return new URL(url, window.location.href).href;
}
async function inlineSnapshotCssUrls(cssText) {
const text = String(cssText || "");
const matches = [...text.matchAll(/url\((["']?)(.*?)\1\)/g)];
if (!matches.length) return text;
const replacements = new Map();
await Promise.all(matches.map(async (match) => {
const rawUrl = match[2].trim();
if (!rawUrl || /^(?:data:|#)/i.test(rawUrl)) return;
const dataUrl = await snapshotResourceDataUrl(rawUrl).catch(() => null);
if (dataUrl) replacements.set(match[0], `url("${dataUrl}")`);
}));
return replacements.size
? text.replace(/url\((["']?)(.*?)\1\)/g, (match) => replacements.get(match) || match)
: text;
}
const snapshotResourceDataUrlCache = new Map();
async function snapshotResourceDataUrl(url) {
if (/^data:/i.test(url)) return url;
if (/^#/i.test(url)) return url;
const absoluteUrl = absoluteSnapshotUrl(url);
if (snapshotResourceDataUrlCache.has(absoluteUrl)) return snapshotResourceDataUrlCache.get(absoluteUrl);
const promise = fetch(absoluteUrl)
.then((response) => {
if (!response.ok) throw new Error(`Snapshot resource failed: ${absoluteUrl}`);
return response.blob();
})
.then(blobToDataUrl);
snapshotResourceDataUrlCache.set(absoluteUrl, promise);
return promise;
}
function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = src;
});
}
async function createCoverHalftoneBlob(image, book, job = null) {
throwIfShareHalftoneJobCancelled(job);
const width = 540;
const height = 720;
const renderScale = SHARE_HALFTONE_RENDER_SCALE;
const sampleScale = 0.25;
const sampleWidth = Math.round(width * sampleScale);
const sampleHeight = Math.round(height * sampleScale);
const sampleCanvas = document.createElement("canvas");
const sampleContext = sampleCanvas.getContext("2d", { willReadFrequently: true });
sampleCanvas.width = sampleWidth;
sampleCanvas.height = sampleHeight;
drawCoverImage(sampleContext, image, 0, 0, sampleWidth, sampleHeight);
const pixels = sampleContext.getImageData(0, 0, sampleWidth, sampleHeight).data;
const luminances = [];
for (let index = 0; index < pixels.length; index += 4) {
luminances.push((0.299 * pixels[index] + 0.587 * pixels[index + 1] + 0.114 * pixels[index + 2]) / 255);
}
const sortedLuminances = [...luminances].sort((a, b) => a - b);
const lowLum = sortedLuminances[Math.floor(sortedLuminances.length * SHARE_HALFTONE_DOT_RULE.luminanceLow)] ?? 0;
const highLum = sortedLuminances[Math.floor(sortedLuminances.length * SHARE_HALFTONE_DOT_RULE.luminanceHigh)] ?? 1;
const lumRange = Math.max(0.12, highLum - lowLum);
const [primaryInk, secondaryInk] = specialHalftonePalette(book) || dominantContrastHalftoneColors(pixels);
const { baseInk, dotInk } = shareHalftonePlateColors(primaryInk, secondaryInk, SHARE_HALFTONE_DOT_RULE);
const toneMaps = buildShareHalftoneToneMaps(pixels, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk);
const rawCanvas = document.createElement("canvas");
const rawContext = rawCanvas.getContext("2d");
rawCanvas.width = width * renderScale;
rawCanvas.height = height * renderScale;
rawContext.scale(renderScale, renderScale);
rawContext.fillStyle = `rgb(${baseInk.r}, ${baseInk.g}, ${baseInk.b})`;
rawContext.fillRect(0, 0, width, height);
const step = SHARE_HALFTONE_DOT_RULE.step;
const dotBuckets = new Map();
let renderedRows = 0;
for (let y = step / 2; y < height; y += step) {
const rowOffset = Math.round(y / step) % 2 ? step * 0.5 : 0;
for (let x = step / 2 + rowOffset; x < width; x += step) {
const { sx, sy } = shareHalftoneSamplePoint(x, y, width, height, sampleWidth, sampleHeight);
const radiusTone = shareHalftonePosterDotToneFromMaps(toneMaps, sx, sy, sampleWidth, sampleHeight);
const noisyTone = shareHalftoneNoisyTone(radiusTone, x, y, SHARE_HALFTONE_DOT_RULE);
const radius = shareHalftoneDotRadius(noisyTone, SHARE_HALFTONE_DOT_RULE);
if (radius <= 0) continue;
const alpha = shareHalftoneDotAlpha(noisyTone);
if (alpha <= 0) continue;
queueShareHalftoneDot(dotBuckets, x, y, radius, alpha);
}
renderedRows += 1;
if (renderedRows % SHARE_HALFTONE_RENDER_YIELD_ROWS === 0) {
throwIfShareHalftoneJobCancelled(job);
await nextShareHalftoneRenderSlice();
}
}
throwIfShareHalftoneJobCancelled(job);
await drawShareHalftoneDots(rawContext, dotBuckets, dotInk, job);
rawContext.globalAlpha = 1;
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
canvas.width = width * renderScale;
canvas.height = height * renderScale;
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.clearRect(0, 0, canvas.width, canvas.height);
const crop = shareHalftoneCropRect(width, height);
context.drawImage(
rawCanvas,
crop.sx * renderScale,
crop.sy * renderScale,
crop.sw * renderScale,
crop.sh * renderScale,
0,
0,
width * renderScale,
height * renderScale,
);
context.globalAlpha = 1;
throwIfShareHalftoneJobCancelled(job);
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (job?.cancelled) {
reject(shareHalftoneCancelledError());
return;
}
if (blob) resolve(blob);
else reject(new Error("Failed to encode share halftone background"));
}, "image/png");
});
}
function buildShareHalftoneToneMaps(pixels, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk) {
const size = sampleWidth * sampleHeight;
const colorTone = new Float32Array(size);
const structureTone = new Float32Array(size);
const luminanceTone = new Float32Array(size);
const dotLighterThanBase = dotInk.l >= baseInk.l;
for (let index = 0; index < size; index += 1) {
const offset = index * 4;
const r = pixels[offset];
const g = pixels[offset + 1];
const b = pixels[offset + 2];
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
const normalized = clamp((luminance - lowLum) / lumRange, 0, 1);
const compressed = clamp((normalized - 0.5) * SHARE_HALFTONE_DOT_RULE.contrast + 0.5, 0, 1);
luminanceTone[index] = compressed;
structureTone[index] = dotLighterThanBase ? compressed : 1 - compressed;
colorTone[index] = shareHalftoneDotToneForPixel(r, g, b, baseInk, dotInk, compressed, SHARE_HALFTONE_DOT_RULE);
}
return { colorTone, structureTone, luminanceTone };
}
function shareHalftonePosterDotToneFromMaps(toneMaps, sx, sy, sampleWidth, sampleHeight) {
const index = clamp(sy, 0, sampleHeight - 1) * sampleWidth + clamp(sx, 0, sampleWidth - 1);
const colorTone = toneMaps.colorTone[index];
const structureTone = toneMaps.structureTone[index];
const posterTone = shareHalftoneSmoothstep(
SHARE_HALFTONE_DOT_RULE.posterThreshold - SHARE_HALFTONE_DOT_RULE.posterSoftness,
SHARE_HALFTONE_DOT_RULE.posterThreshold + SHARE_HALFTONE_DOT_RULE.posterSoftness,
structureTone,
);
const edgeTone = shareHalftonePosterEdgeToneFromMaps(toneMaps, sx, sy, sampleWidth, sampleHeight);
return clamp(
colorTone * (1 - SHARE_HALFTONE_DOT_RULE.posterWeight)
+ posterTone * SHARE_HALFTONE_DOT_RULE.posterWeight
+ edgeTone * SHARE_HALFTONE_DOT_RULE.edgeWeight,
0,
1,
);
}
function shareHalftonePosterEdgeToneFromMaps(toneMaps, sx, sy, sampleWidth, sampleHeight) {
const centerIndex = clamp(sy, 0, sampleHeight - 1) * sampleWidth + clamp(sx, 0, sampleWidth - 1);
const centerLuminanceTone = toneMaps.luminanceTone[centerIndex];
const centerShape = toneMaps.colorTone[centerIndex];
const radius = Math.max(1, Math.round(SHARE_HALFTONE_DOT_RULE.edgeRadius || 1));
const offsets = [
[-radius, 0],
[radius, 0],
[0, -radius],
[0, radius],
];
const maxEdge = offsets.reduce((peak, [dx, dy]) => {
const x = clamp(sx + dx, 0, sampleWidth - 1);
const y = clamp(sy + dy, 0, sampleHeight - 1);
const index = y * sampleWidth + x;
return Math.max(
peak,
Math.abs(centerLuminanceTone - toneMaps.luminanceTone[index]) * 0.72
+ Math.abs(centerShape - toneMaps.colorTone[index]) * 0.28,
);
}, 0);
return clamp(maxEdge * 1.8, 0, 1);
}
function shareHalftoneSmoothstep(edge0, edge1, value) {
const t = clamp((value - edge0) / (edge1 - edge0 || 1), 0, 1);
return t * t * (3 - 2 * t);
}
function queueShareHalftoneDot(buckets, x, y, radius, alpha) {
const radiusKey = Math.max(1, Math.round(radius * SHARE_HALFTONE_RADIUS_BUCKETS));
const alphaKey = Math.round(alpha * 100);
const key = `${radiusKey}:${alphaKey}`;
const bucketRadius = radiusKey / SHARE_HALFTONE_RADIUS_BUCKETS;
let bucket = buckets.get(key);
if (!bucket) {
bucket = {
alpha: alphaKey / 100,
radius: bucketRadius,
dots: [],
};
buckets.set(key, bucket);
}
bucket.dots.push({ x, y });
}
async function drawShareHalftoneDots(context, buckets, dotInk, job = null) {
context.fillStyle = `rgb(${dotInk.r}, ${dotInk.g}, ${dotInk.b})`;
if (supportsShareHalftonePath2D()) {
try {
let bucketIndex = 0;
for (const bucket of buckets.values()) {
throwIfShareHalftoneJobCancelled(job);
const path = new Path2D();
bucket.dots.forEach((dot) => {
path.moveTo(dot.x + bucket.radius, dot.y);
path.arc(dot.x, dot.y, bucket.radius, 0, Math.PI * 2);
});
context.globalAlpha = bucket.alpha;
context.fill(path);
bucketIndex += 1;
if (bucketIndex % SHARE_HALFTONE_RENDER_YIELD_BUCKETS === 0) {
await nextShareHalftoneRenderSlice();
}
}
return;
} catch (error) {
console.warn("Share halftone batched dot drawing failed; falling back", error);
}
}
await drawShareHalftoneDotsIndividually(context, buckets, job);
}
async function drawShareHalftoneDotsIndividually(context, buckets, job = null) {
let bucketIndex = 0;
for (const bucket of buckets.values()) {
throwIfShareHalftoneJobCancelled(job);
context.globalAlpha = bucket.alpha;
bucket.dots.forEach((dot) => {
context.beginPath();
context.arc(dot.x, dot.y, bucket.radius, 0, Math.PI * 2);
context.fill();
});
bucketIndex += 1;
if (bucketIndex % SHARE_HALFTONE_RENDER_YIELD_BUCKETS === 0) {
await nextShareHalftoneRenderSlice();
}
}
}
function throwIfShareHalftoneJobCancelled(job) {
if (job?.cancelled) throw shareHalftoneCancelledError();
}
function nextShareHalftoneRenderSlice() {
return new Promise((resolve) => window.setTimeout(resolve, 0));
}
let shareHalftonePath2DSupported = null;
function supportsShareHalftonePath2D() {
if (shareHalftonePath2DSupported !== null) return shareHalftonePath2DSupported;
if (typeof Path2D !== "function") {
shareHalftonePath2DSupported = false;
return shareHalftonePath2DSupported;
}
try {
const canvas = document.createElement("canvas");
canvas.width = 8;
canvas.height = 8;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
shareHalftonePath2DSupported = false;
return shareHalftonePath2DSupported;
}
const path = new Path2D();
path.arc(4, 4, 3, 0, Math.PI * 2);
context.fillStyle = "#000";
context.fill(path);
shareHalftonePath2DSupported = context.getImageData(4, 4, 1, 1).data[3] > 0;
} catch {
shareHalftonePath2DSupported = false;
}
return shareHalftonePath2DSupported;
}
function dominantContrastHalftoneColors(pixels) {
const buckets = new Map();
for (let index = 0; index < pixels.length; index += 16) {
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
const hsl = rgbToHsl(r, g, b);
if (hsl.s < 0.06 || (hsl.s < 0.1 && luminance > 0.64)) continue;
const weight = 1;
const hueBucket = Math.round(hsl.h / 16) * 16;
const saturationBucket = Math.round(hsl.s * 6) / 6;
const luminanceBucket = Math.round(hsl.l * 7) / 7;
const key = `${hueBucket},${saturationBucket},${luminanceBucket}`;
const bucket = buckets.get(key) || { r: 0, g: 0, b: 0, weight: 0 };
bucket.r += r * weight;
bucket.g += g * weight;
bucket.b += b * weight;
bucket.weight += weight;
buckets.set(key, bucket);
}
const colors = [...buckets.values()]
.filter((bucket) => bucket.weight > 0)
.map((bucket) => ({
r: bucket.r / bucket.weight,
g: bucket.g / bucket.weight,
b: bucket.b / bucket.weight,
weight: bucket.weight,
}))
.map((color) => ({ ...color, ...rgbToHsl(color.r, color.g, color.b) }))
.sort((a, b) => b.weight - a.weight);
const [primarySource, secondarySource] = shareHalftoneFeatureColorPair(colors);
return [
printableInkColor(primarySource || { r: 42, g: 82, b: 62 }, "primary"),
printableInkColor(secondarySource || { r: 186, g: 94, b: 76 }, "secondary"),
];
}
function specialHalftonePalette(book) {
const colors = SHARE_HALFTONE_SPECIAL_PALETTES.get(bookIdentity(book));
if (!colors) return null;
return colors.map((color, index) => printableInkColor(color, index === 0 ? "primary" : "secondary"));
}
function bookIdentity(book) {
return book?.bookId || book?.id || "";
}
function cityAnalyticsProps(city, extra = {}) {
return {
city_id: city?.cityId || city?.id || "",
city_name: city?.cityZh || city?.cityEn || "",
city_name_en: city?.cityEn || "",
country: city?.countryZh || city?.countryEn || "",
country_code: city?.isoA3 || city?.countryCode || "",
...extra,
};
}
function bookAnalyticsProps(city, book, extra = {}) {
return {
...cityAnalyticsProps(city),
book_id: bookIdentity(book),
book_title: fullBookTitle(book, city),
book_title_original: book?.titleOriginal || "",
book_author: Array.isArray(book?.authors) ? book.authors.filter(Boolean).join(" / ") : "",
is_special_book: Boolean(city?.specialContext),
special_id: city?.specialContext?.id || "",
special_category: city?.specialContext?.category || "",
...extra,
};
}
function searchAnalyticsProps(query, extra = {}) {
const normalized = normalizeSearchText(query);
return {
query_length: normalized.length,
query_has_chinese: /[\u4e00-\u9fff]/.test(normalized),
query_has_latin: /[a-z]/.test(normalized),
...extra,
};
}
function analyticsSearchText(query) {
return normalizeSearchText(query)
.replace(/[\u0000-\u001f\u007f]/gu, "")
.replace(/\s+/gu, " ")
.slice(0, 80);
}
function analyticsAssetPath(src) {
if (!src) return "";
try {
const url = new URL(src, window.location.href);
if (url.origin === window.location.origin) return url.pathname;
return url.hostname;
} catch (error) {
return String(src).slice(0, 120);
}
}
function trackBookTicketOpened(city, sourceView, extra = {}) {
const book = activeBook(city);
if (!city || !book) return;
trackEvent("book_ticket_opened", bookAnalyticsProps(city, book, {
source_view: sourceView,
book_index: activeBookIndex(city),
book_count: pointBooks(city).length,
...extra,
}));
}
function trackShareRenderFailed(point, book, reason) {
trackEvent("share_render_failed", bookAnalyticsProps(point, book, {
source_view: "share_preview",
reason,
}));
}
function trackSpecialStackSwitched(special, group, stackKey, targetIndex, sourceView) {
const entry = group.entries[targetIndex] || group.entries[0];
const book = entry?.book;
trackEvent("special_stack_switched", {
special_id: special.id,
special_name: special.specialName || "",
special_category: group.category,
stack_key: stackKey,
source_view: sourceView,
book_index: targetIndex,
stack_count: group.entries.length,
book_id: bookIdentity(book),
book_title: book?.titleZh || book?.titleOriginal || "",
});
}
function contrastHalftoneColorPair(colors) {
if (!colors.length) return [];
if (colors.length === 1) return [colors[0], colors[0]];
const candidates = colors.slice(0, 28);
const topWeight = candidates[0]?.weight || 1;
let bestPair = [candidates[0], candidates[0]];
let bestScore = -Infinity;
candidates.forEach((first, firstIndex) => {
candidates.slice(firstIndex + 1).forEach((second) => {
const hueGap = hueDistance(first.h, second.h);
if (hueGap < 42) return;
const smallerArea = Math.min(first.weight, second.weight) / topWeight;
if (smallerArea < 0.18 || firstIndex > 5) return;
const totalArea = (first.weight + second.weight) / (topWeight * 2);
const balance = Math.min(first.weight, second.weight) / Math.max(first.weight, second.weight);
const contrast = Math.exp(-Math.pow((hueGap - 150) / 70, 2));
const chroma = Math.min((first.s + second.s) / 2, 0.72);
const warmCoolBonus = isWarmHue(first.h) !== isWarmHue(second.h) ? 0.08 : 0;
const score = totalArea * 0.56 + smallerArea * 0.16 + contrast * 0.16 + balance * 0.06 + chroma * 0.04 + warmCoolBonus;
if (score > bestScore) {
bestScore = score;
bestPair = [first, second];
}
});
});
if (bestScore < 0) return [candidates[0], candidates[0]];
return orderHalftoneColorPair(bestPair[0], bestPair[1]);
}
function orderHalftoneColorPair(first, second) {
if (!first || !second) return [first, second];
if (first.l !== second.l) return first.l < second.l ? [first, second] : [second, first];
return first.weight >= second.weight ? [first, second] : [second, first];
}
function printableInkColor(color, role = "primary") {
const hsl = rgbToHsl(color.r, color.g, color.b);
const saturation = hsl.s < 0.16
? clamp(hsl.s * 0.96, 0.04, 0.16)
: role === "primary"
? clamp(hsl.s * 0.94, 0.18, 0.58)
: clamp(hsl.s * 0.92, 0.18, 0.56);
const lightness = role === "primary"
? clamp(hsl.l - 0.04, 0.34, 0.85)
: hsl.l < 0.2
? clamp(hsl.l - 0.02, 0.08, 0.2)
: clamp(hsl.l - 0.02, 0.38, 0.85);
const [r, g, b] = hslToRgb(hsl.h, saturation, lightness);
return { r, g, b, h: hsl.h, s: saturation, l: lightness };
}
function colorSaturation(r, g, b) {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
return max === 0 ? 0 : (max - min) / max;
}
function colorDistance(a, b) {
return Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b);
}
function rgbToHsl(r, g, b) {
const red = r / 255;
const green = g / 255;
const blue = b / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
let hue = 0;
let saturation = 0;
const lightness = (max + min) / 2;
if (max !== min) {
const delta = max - min;
saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
if (max === red) {
hue = (green - blue) / delta + (green < blue ? 6 : 0);
} else if (max === green) {
hue = (blue - red) / delta + 2;
} else {
hue = (red - green) / delta + 4;
}
hue *= 60;
}
return { h: hue, s: saturation, l: lightness };
}
function hslToRgb(hue, saturation, lightness) {
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
const huePrime = hue / 60;
const x = chroma * (1 - Math.abs((huePrime % 2) - 1));
let red = 0;
let green = 0;
let blue = 0;
if (huePrime < 1) {
red = chroma;
green = x;
} else if (huePrime < 2) {
red = x;
green = chroma;
} else if (huePrime < 3) {
green = chroma;
blue = x;
} else if (huePrime < 4) {
green = x;
blue = chroma;
} else if (huePrime < 5) {
red = x;
blue = chroma;
} else {
red = chroma;
blue = x;
}
const match = lightness - chroma / 2;
return [
Math.round((red + match) * 255),
Math.round((green + match) * 255),
Math.round((blue + match) * 255),
];
}
function hueDistance(firstHue, secondHue) {
const difference = Math.abs(firstHue - secondHue) % 360;
return Math.min(difference, 360 - difference);
}
function isWarmHue(hue) {
return hue <= 70 || hue >= 300;
}
function drawCoverImage(context, image, x, y, width, height, zoom = 1) {
const sourceRatio = image.naturalWidth / image.naturalHeight;
const targetRatio = width / height;
let sx = 0;
let sy = 0;
let sw = image.naturalWidth;
let sh = image.naturalHeight;
if (sourceRatio > targetRatio) {
sw = image.naturalHeight * targetRatio;
sx = (image.naturalWidth - sw) / 2;
} else {
sh = image.naturalWidth / targetRatio;
sy = 0;
}
if (zoom > 1) {
const zoomedWidth = sw / zoom;
const zoomedHeight = sh / zoom;
sx += (sw - zoomedWidth) / 2;
sy += (sh - zoomedHeight) / 2;
sw = zoomedWidth;
sh = zoomedHeight;
}
context.drawImage(image, sx, sy, sw, sh, x, y, width, height);
}
function slugify(text) {
return String(text || "")
.trim()
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.slice(0, 48);
}
function cityHeading(city) {
return [city.cityZh, city.cityEn].filter(Boolean).join(" ");
}
function fullBookTitle(book, city) {
if (book.fullTitleZh) return book.fullTitleZh;
if (book.titleZh && book.subtitleZh) return `${book.titleZh}${book.subtitleZh}`;
return book.titleZh || city.cityZh || city.cityEn || "";
}
function titleLayoutForBook(book, city) {
const fallback = fullBookTitle(book, city);
const title = book?.titleZh || "";
const subtitle = book?.subtitleZh || "";
if (fallback === "民族国家构建视角下的苏丹内战研究") {
return { mode: "split", primary: "民族国家构建", secondary: "视角下的苏丹内战研究" };
}
if (!hasChineseText(fallback)) {
return { mode: "original", primary: fallback, secondary: "" };
}
if (subtitle) {
const total = titleVisualLength(fallback);
const primary = titleVisualLength(title);
if (total > 12.5 || primary > 9) {
return { mode: "subtitle", primary: title, secondary: subtitle, longSubtitle: titleVisualLength(subtitle) >= 30 };
}
}
return { mode: "single", primary: fallback, secondary: "" };
}
function renderBookTitle(element, layout) {
element.classList.toggle("is-title-split", Boolean(layout.secondary));
element.classList.toggle("is-title-subtitle", layout.mode === "subtitle");
element.classList.toggle("is-title-equal-split", layout.mode === "split");
element.classList.toggle("is-title-original", layout.mode === "original");
element.classList.toggle("is-title-long-subtitle", Boolean(layout.longSubtitle));
const primary = document.createElement("span");
primary.className = "book-card__title-main";
primary.textContent = layout.primary;
if (!layout.secondary) {
element.replaceChildren(primary);
return;
}
const secondary = document.createElement("span");
secondary.className = "book-card__title-sub";
secondary.textContent = layout.secondary;
element.replaceChildren(primary, secondary);
}
function hasChineseText(text) {
return /[\u4e00-\u9fff]/.test(String(text || ""));
}
function titleVisualLength(text) {
return [...String(text || "")].reduce((total, character) => {
if (/\s/.test(character)) return total;
if (/[\u4e00-\u9fff]/.test(character)) return total + 1;
if (/[A-Za-z0-9]/.test(character)) return total + 0.55;
return total + 0.35;
}, 0);
}
function bookMetaParts(book, options = {}) {
if (options.fromDataset) {
return {
authors: book.authors?.[0] || "",
details: book.publisher || "",
};
}
return {
authors: book.authors?.length ? book.authors.join(" / ") : "",
details: [book.publisher, book.year].filter(Boolean).join(" · "),
};
}
function renderBookMeta(element, book, options = {}) {
const { authors, details } = bookMetaParts(book, options);
element.dataset.authors = authors;
element.dataset.details = details;
element.textContent = "";
if (!options.split || !authors || !details) {
element.textContent = [authors, details].filter(Boolean).join(" · ");
return;
}
const authorLine = document.createElement("span");
authorLine.className = "book-card__meta-authors";
authorLine.textContent = authors;
const detailLine = document.createElement("span");
detailLine.className = "book-card__meta-details";
detailLine.textContent = details;
element.append(authorLine, detailLine);
}
function bookMeta(book) {
const { authors, details } = bookMetaParts(book);
return [authors, details].filter(Boolean).join(" · ");
}
function recommendationText(book) {
if (book.oneLine) return book.oneLine;
if (Array.isArray(book.recommendation) && book.recommendation.length) {
return book.recommendation.join(" ");
}
return book.summary || book.sourceIntro || "这座城市的第一本入口书,等待补充更完整的推荐理由。";
}
function shortBookText(book) {
const explicitShortText = book.oneLine || (
Array.isArray(book.recommendation) && book.recommendation.length
? book.recommendation.join(" ")
: ""
);
return truncateText(explicitShortText, 76, { preserveLineBreaks: true });
}
function detailBookText(book) {
return normalizeDisplayText(book.summary || book.sourceIntro || recommendationText(book), { preserveLineBreaks: true });
}
function detailBookParagraphs(book) {
const text = detailBookText(book);
if (!text) return [];
const blankLineParagraphs = text.split(/\n{2,}/).map((paragraph) => paragraph.trim()).filter(Boolean);
const paragraphs = blankLineParagraphs.length >= 2
? blankLineParagraphs
: text.split(/\n+/).map((paragraph) => paragraph.trim()).filter(Boolean);
return paragraphs.slice(0, 3);
}
function appendTextWithMarkdownLinks(element, text) {
const linkPattern = /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g;
let cursor = 0;
let match = linkPattern.exec(text);
while (match) {
const [source, label, href] = match;
if (match.index > cursor) {
element.append(document.createTextNode(text.slice(cursor, match.index)));
}
let safeUrl = null;
try {
const url = new URL(href);
if (url.protocol === "http:" || url.protocol === "https:") safeUrl = url.href;
} catch {
safeUrl = null;
}
if (safeUrl) {
const link = document.createElement("a");
link.href = safeUrl;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = label;
element.append(link);
} else {
element.append(document.createTextNode(source));
}
cursor = match.index + source.length;
match = linkPattern.exec(text);
}
if (cursor < text.length) {
element.append(document.createTextNode(text.slice(cursor)));
}
}
function renderDetailBookIntro(element, book) {
const paragraphs = detailBookParagraphs(book);
element.replaceChildren();
element.hidden = paragraphs.length === 0;
clearDetailIntroFit(element);
paragraphs.forEach((paragraphText) => {
const paragraph = document.createElement("p");
appendTextWithMarkdownLinks(paragraph, paragraphText);
element.append(paragraph);
});
}
function normalizeDisplayText(text, { preserveLineBreaks = false } = {}) {
const raw = String(text || "").replace(/\r\n?/g, "\n");
if (!preserveLineBreaks) return raw.replace(/\s+/g, " ").trim();
return raw
.split("\n")
.map((line) => line.replace(/[ \t\f\v]+/g, " ").trim())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function truncateText(text, maxLength, options = {}) {
const clean = normalizeDisplayText(text, options);
if (clean.length <= maxLength) return clean;
return `${clean.slice(0, maxLength - 1)}…`;
}
function pointBooks(point) {
if (!point) return [];
const books = Array.isArray(point.books) && point.books.length ? point.books : [point];
if (point.specialContext) {
return books.filter((book) => !book.isPlaceholder);
}
const selectedBooks = books.filter(bookIsSelected);
if (point.searchContext) {
return selectedBooks;
}
if (SPECIAL_CITY_IDS.has(point.cityId || point.id)) {
return selectedBooks.slice(0, SPECIAL_CITY_GLOBAL_BOOK_LIMIT);
}
return selectedBooks;
}
function activeBookIndex(point) {
if (!point) return 0;
const books = pointBooks(point);
const index = state.activeBookIndexes.get(point.id) || 0;
return clampBookIndex(point, index);
}
function clampBookIndex(point, index) {
const books = pointBooks(point);
return clamp(Number.isFinite(index) ? index : 0, 0, Math.max(books.length - 1, 0));
}
function activeBook(point) {
const books = pointBooks(point);
return books[activeBookIndex(point)] || null;
}
function captureDetailOrigin() {
const point = state.activePoint;
if (!point) return null;
return {
point,
pointId: point.id,
bookIndex: activeBookIndex(point),
explored: false,
};
}
function restoreDetailOriginBookmark() {
const origin = state.detailOrigin;
const point = state.activePoint || origin?.point;
if (!point) return false;
if (!state.activePoint) {
state.activePoint = point;
state.activeBookIndexes.set(origin.pointId, origin.bookIndex);
}
state.bookCardMode = "ticket";
state.detailOrigin = null;
syncCityMarkerActiveClasses();
syncTooltip(createProjection());
return true;
}
function returnToBookshelfFromDetail() {
state.detailReturnTarget = null;
closeTooltip();
openBookshelf();
}
function shouldReturnToDetailOrigin() {
const origin = state.detailOrigin;
if (!origin || state.bookCardMode !== "detail") return false;
if (pointUsesSpecialBookmarkLayer(state.activePoint) || pointUsesSpecialBookmarkLayer(origin.point)) return false;
return origin.explored;
}
function handleTooltipClose() {
if (state.detailReturnTarget === "bookshelf" && state.bookCardMode === "detail") {
returnToBookshelfFromDetail();
return;
}
if (shouldReturnToDetailOrigin() && restoreDetailOriginBookmark()) return;
closeTooltip();
}
function cityExplorePoints() {
return mapData.cities
.filter((city) => pointBooks(city).length > 0 && Number.isFinite(cityCoordinates(city)[0]))
.slice()
.sort((a, b) => {
const [aLon, aLat] = cityCoordinates(a);
const [bLon, bLat] = cityCoordinates(b);
return aLon - bLon || aLat - bLat || String(a.id).localeCompare(String(b.id));
});
}
function activeCityExploreIndex(cities) {
const activeId = state.activePoint?.id;
const exactIndex = cities.findIndex((city) => city.id === activeId);
if (exactIndex >= 0) return exactIndex;
const activeLon = cityCoordinates(state.activePoint || {})[0];
if (!Number.isFinite(activeLon)) return 0;
const nextIndex = cities.findIndex((city) => cityCoordinates(city)[0] >= activeLon);
return nextIndex >= 0 ? nextIndex : 0;
}
function pageActiveCity(delta) {
if (state.activePoint?.specialContext) return;
const cities = cityExplorePoints();
if (!cities.length || state.bookCardMode !== "detail") return;
if (!state.detailOrigin) state.detailOrigin = captureDetailOrigin();
const nextIndex = (activeCityExploreIndex(cities) + delta + cities.length) % cities.length;
state.activePoint = cities[nextIndex];
trackEvent("city_paged", cityAnalyticsProps(state.activePoint, {
source_view: "book_detail",
direction: delta > 0 ? "next" : "previous",
city_index: nextIndex,
city_count: cities.length,
}));
if (state.detailOrigin) state.detailOrigin.explored = true;
state.bookCardMode = "detail";
syncCityMarkerActiveClasses();
updateActiveDetailAtFixedAnchor();
playUiSound("pageFlick");
}
function pageActiveBook(delta) {
const point = state.activePoint;
const books = pointBooks(point);
if (!point || books.length <= 1) return;
const nextIndex = (activeBookIndex(point) + delta + books.length) % books.length;
state.activeBookIndexes.set(point.id, nextIndex);
trackEvent("book_paged", bookAnalyticsProps(point, books[nextIndex], {
source_view: state.bookCardMode === "detail" ? "book_detail" : "book_ticket",
direction: delta > 0 ? "next" : "previous",
book_index: nextIndex,
book_count: books.length,
}));
if (point.specialContext) {
trackEvent("city_special_book_paged", bookAnalyticsProps(point, books[nextIndex], {
source_view: "city_special_detail",
direction: delta > 0 ? "next" : "previous",
book_index: nextIndex,
book_count: books.length,
special_id: point.specialContext.id || "",
special_category: point.specialContext.category || "",
}));
}
playUiSound("pageFlick");
if (state.bookCardMode === "detail") {
if (!state.detailOrigin) state.detailOrigin = captureDetailOrigin();
if (state.detailOrigin) state.detailOrigin.explored = true;
updateActiveDetailAtFixedAnchor();
} else {
syncTooltip(createProjection());
}
}
function cityCoordinates(city) {
const [lon, lat] = city.coordinates || [city.lon, city.lat];
return [Number(lon), Number(lat)];
}
function cityIsCapital(city) {
const roles = Array.isArray(city.cityRole) ? city.cityRole : [city.cityRole].filter(Boolean);
return city.isCapital === true || roles.includes("national_capital");
}
function cityMarkerScale(city) {
return cityIsCapital(city) ? CAPITAL_MARKER_SCALE : 1;
}
function cityRadius(city) {
return 3.43 * cityMarkerScale(city);
}
function cityHaloRadius(city) {
return 5.17 * cityMarkerScale(city);
}
function cityWeightClass(city) {
return Number(city.displayWeight || 0) >= 90 ? "city-point--major" : "city-point--standard";
}
function pointLabel(city) {
const books = pointBooks(city);
const bookTitle = books[0]?.titleZh ? `,${books[0].titleZh}` : "";
const count = books.length > 1 ? `,共${books.length}本书` : "";
return `${city.cityZh || city.cityEn}${city.countryZh || city.countryEn}${bookTitle}${count}`;
}
function clampTooltipPoint(point) {
const { width: cardWidth, height: cardHeight } = tooltipCardSize();
const bounds = tooltipBounds(cardWidth, cardHeight);
return {
x: clamp(point.x, bounds.minX, bounds.maxX),
y: clamp(point.y, bounds.minY, bounds.maxY),
};
}
function updateActiveDetailAtFixedAnchor() {
if (!state.activePoint) return;
updateTooltipContent(state.activePoint);
elements.cityTooltip.hidden = false;
placeDetailTooltip();
requestAnimationFrame(() => {
placeDetailTooltip();
requestAnimationFrame(() => placeDetailTooltip());
});
document.fonts?.ready.then(() => placeDetailTooltip());
}
function syncCityMarkerActiveClasses() {
cityMarkerCache.forEach((markerEntry, cityId) => {
markerEntry.element.classList.toggle("is-active", cityId === state.activePoint?.id);
});
}
function tooltipCardSize() {
return {
width: elements.cityTooltip.offsetWidth || 456,
height: elements.cityTooltip.offsetHeight || 218,
};
}
function placeTooltipAtAnchor(anchor, city, options = {}) {
const wasHidden = elements.cityTooltip.hidden;
const previousVisibility = elements.cityTooltip.style.visibility;
if (wasHidden) {
elements.cityTooltip.style.visibility = "hidden";
elements.cityTooltip.hidden = false;
}
const anchoredPoint = resolveTooltipPoint(anchor, city, options);
state.tooltipPoint = anchoredPoint;
elements.cityTooltip.style.left = `${anchoredPoint.x}px`;
elements.cityTooltip.style.top = `${anchoredPoint.y}px`;
syncTooltipSafeSize();
if (wasHidden) elements.cityTooltip.style.visibility = previousVisibility;
elements.cityTooltip.hidden = false;
}
function placeDetailTooltip() {
const wasHidden = elements.cityTooltip.hidden;
const previousVisibility = elements.cityTooltip.style.visibility;
if (wasHidden) {
elements.cityTooltip.style.visibility = "hidden";
elements.cityTooltip.hidden = false;
}
const { width: cardWidth, height: cardHeight } = tooltipCardSize();
const stageRect = elements.mapStage.getBoundingClientRect();
const bounds = detailTooltipBounds(cardWidth, cardHeight);
const copyColumnOffset = detailCopyColumnOffset();
const copyColumnWidth = detailCopyColumnWidth();
const copyAnchorX = detailCopyAnchorX(stageRect, copyColumnOffset, copyColumnWidth, bounds);
const point = {
x: clamp(copyAnchorX - copyColumnOffset - copyColumnWidth / 2, bounds.minX, bounds.maxX),
y: clamp((stageRect.height - cardHeight) / 2, bounds.minY, bounds.maxY),
};
state.tooltipPoint = point;
elements.cityTooltip.style.left = `${point.x}px`;
elements.cityTooltip.style.top = `${point.y}px`;
syncTooltipSafeSize();
if (wasHidden) elements.cityTooltip.style.visibility = previousVisibility;
elements.cityTooltip.hidden = false;
}
function detailTooltipBounds(cardWidth, cardHeight) {
const stageRect = elements.mapStage.getBoundingClientRect();
const rightEdge = stageRect.width - GLOBAL_TOOLTIP_SAFE_ZONE.right;
const leftEdge = detailTooltipLeftSafeEdge(stageRect, cardWidth, rightEdge);
const topEdge = tooltipTopSafeEdge(stageRect);
const bottomEdge = tooltipBottomSafeEdge(stageRect);
const safeWidth = detailTooltipSafeWidth(stageRect, cardWidth, rightEdge);
elements.cityTooltip.style.setProperty("--tooltip-safe-width", `${Math.floor(safeWidth)}px`);
return {
minX: leftEdge,
maxX: Math.max(rightEdge - cardWidth, leftEdge),
minY: topEdge,
maxY: Math.max(bottomEdge - cardHeight, topEdge),
};
}
function detailTooltipLeftSafeEdge(stageRect, cardWidth, rightEdge) {
const fallbackLeft = GLOBAL_TOOLTIP_SAFE_ZONE.inset;
const rightShiftedLeft = rightEdge - cardWidth;
const heroSafeLeft = detailHeroSafeLeft(stageRect, cardWidth, fallbackLeft);
return Math.max(rightShiftedLeft, heroSafeLeft, fallbackLeft);
}
function detailHeroSafeLeft(stageRect, cardWidth, fallbackLeft) {
if (!elements.heroCopy || elements.heroCopy.hidden) return fallbackLeft;
const heroRect = elements.heroCopy.getBoundingClientRect();
if (!heroRect.width || heroRect.right <= stageRect.left || heroRect.left >= stageRect.right) return fallbackLeft;
const allowedOverlap = detailAllowsHeroOverlap(cardWidth) ? DETAIL_WIDE_HERO_OVERLAP : 0;
return Math.max(fallbackLeft, heroRect.right - stageRect.left + DETAIL_HERO_CLEARANCE - allowedOverlap);
}
function detailAllowsHeroOverlap(cardWidth) {
return cardWidth >= DETAIL_WIDE_CARD_THRESHOLD
|| Boolean(elements.cityTooltip.querySelector(".book-card__detail-view.is-wide-detail-cover"));
}
function detailTooltipSafeWidth(stageRect, cardWidth, rightEdge) {
const contentLeft = detailHeroSafeLeft(stageRect, cardWidth, GLOBAL_TOOLTIP_SAFE_ZONE.inset);
return Math.max(rightEdge - contentLeft, 220);
}
function detailCopyColumnOffset() {
const cardRect = elements.cityTooltip.getBoundingClientRect();
const copyRect = elements.cityTooltip.querySelector(".book-card__detail-body")?.getBoundingClientRect();
if (!cardRect.width || !copyRect?.width) return 0;
return Math.max(0, copyRect.left - cardRect.left);
}
function detailCopyColumnWidth() {
const copyRect = elements.cityTooltip.querySelector(".book-card__detail-body")?.getBoundingClientRect();
return copyRect?.width || 360;
}
function detailCopyAnchorX(stageRect, copyColumnOffset, copyColumnWidth, bounds) {
const desiredAnchor = stageRect.width * DETAIL_COPY_ANCHOR_RATIO;
const minAnchor = bounds.minX + copyColumnOffset + copyColumnWidth / 2;
const maxAnchor = bounds.maxX + copyColumnOffset + copyColumnWidth / 2;
return clamp(desiredAnchor, minAnchor, Math.max(maxAnchor, minAnchor));
}
function resolveTooltipPoint(anchor, city, options = {}) {
const { width: cardWidth, height: cardHeight } = tooltipCardSize();
const bounds = tooltipBounds(cardWidth, cardHeight);
const markerBoxes = options.avoidMarker ? activeMarkerAvoidanceBoxes(city) : [];
const avoidBoxes = markerBoxes;
const candidates = tooltipPositionCandidates(anchor, cardWidth, cardHeight, bounds, {
markerBoxes,
});
return candidates
.map((candidate) => {
const x = clamp(candidate.x, bounds.minX, bounds.maxX);
const y = clamp(candidate.y, bounds.minY, bounds.maxY);
const box = {
left: x,
right: x + cardWidth,
top: y,
bottom: y + cardHeight,
};
const outOfBounds =
Math.max(0, bounds.minX - candidate.x) +
Math.max(0, candidate.x - bounds.maxX) +
Math.max(0, bounds.minY - candidate.y) +
Math.max(0, candidate.y - bounds.maxY);
const overlapPenalty = avoidBoxes.reduce(
(score, avoidBox) => score + overlapArea(box, avoidBox) * (avoidBox.weight || 80),
0,
);
const centerDistance = Math.hypot(x + cardWidth / 2 - anchor.x, y + cardHeight / 2 - anchor.y);
return {
x,
y,
score:
candidate.priority +
overlapPenalty +
outOfBounds * 260 +
Math.max(0, centerDistance - 120) * 2.4,
};
})
.sort((a, b) => a.score - b.score)[0] || clampTooltipPoint(anchor);
}
function tooltipPositionCandidates(anchor, cardWidth, cardHeight, bounds, context = {}) {
const gap = 2;
const verticalBase = anchor.y - cardHeight * TOOLTIP_CARD_ANCHOR_RATIO;
const verticalOffsets = [0, -28, 28, -56, 56, -92, 92];
const markerRight = context.markerBoxes?.length ? Math.max(...context.markerBoxes.map((box) => box.right)) : null;
const markerLeft = context.markerBoxes?.length ? Math.min(...context.markerBoxes.map((box) => box.left)) : null;
const rightClearX = Math.max(
anchor.x + gap,
markerRight == null ? bounds.minX : markerRight + 2,
);
const leftClearX = Math.min(
anchor.x - cardWidth - gap,
markerLeft == null ? bounds.maxX : markerLeft - cardWidth - 2,
);
const horizontalCandidates = [
{ key: "right-clear", x: rightClearX, priority: 0 },
{ key: "right-near", x: anchor.x + gap, priority: 120 },
{ key: "left-clear", x: leftClearX, priority: 2600 },
{ key: "left-near", x: anchor.x - cardWidth - gap, priority: 3400 },
];
return horizontalCandidates.flatMap((horizontal) =>
verticalOffsets.map((offset, index) => ({
key: `${horizontal.key}-${index}`,
x: horizontal.x,
y: verticalBase + offset,
priority: horizontal.priority + index * 80 + Math.abs(offset) * 2,
})),
);
}
function activeMarkerAvoidanceBoxes(city) {
const markerEntry = cityMarkerCache.get(city?.id);
if (!markerEntry) return [];
const boxes = [];
const labelBox = cityLabelTextStageBox(markerEntry.label, 6);
if (labelBox) boxes.push({ ...labelBox, weight: 160 });
const haloBox = elementStageBox(markerEntry.halo || markerEntry.pointElement || markerEntry.element, 4);
if (haloBox) boxes.push({ ...haloBox, weight: 120 });
return boxes;
}
function heroAvoidanceBoxes() {
if (state.activeSpecialId || !elements.heroCopy || elements.heroCopy.hidden) return [];
const box = elementStageBox(elements.heroCopy, 16);
return box ? [{ ...box, weight: 100 }] : [];
}
function elementStageBox(element, padding = 0) {
if (!element) return null;
const rect = element.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const stageRect = elements.mapStage.getBoundingClientRect();
return {
left: rect.left - stageRect.left - padding,
right: rect.right - stageRect.left + padding,
top: rect.top - stageRect.top - padding,
bottom: rect.bottom - stageRect.top + padding,
};
}
function cityLabelTextStageBox(label, padding = 0) {
if (!label) return null;
const stageRect = elements.mapStage.getBoundingClientRect();
const textRects = [...label.querySelectorAll(".city-html-marker__zh, .city-html-marker__en")]
.filter((element) => !element.hidden)
.map((element) => {
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return null;
const rect = element.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
return rect;
})
.filter(Boolean);
if (!textRects.length) return elementStageBox(label, padding);
return {
left: Math.min(...textRects.map((rect) => rect.left)) - stageRect.left - padding,
right: Math.max(...textRects.map((rect) => rect.right)) - stageRect.left + padding,
top: Math.min(...textRects.map((rect) => rect.top)) - stageRect.top - padding,
bottom: Math.max(...textRects.map((rect) => rect.bottom)) - stageRect.top + padding,
};
}
function tooltipBounds(cardWidth, cardHeight) {
const stageRect = elements.mapStage.getBoundingClientRect();
const leftEdge = tooltipLeftSafeEdge(stageRect);
const rightEdge = tooltipRightSafeEdge(stageRect);
const topEdge = tooltipTopSafeEdge(stageRect);
const bottomEdge = tooltipBottomSafeEdge(stageRect);
const safeWidth = Math.max(rightEdge - leftEdge, 220);
elements.cityTooltip.style.setProperty("--tooltip-safe-width", `${Math.floor(safeWidth)}px`);
return {
minX: leftEdge,
maxX: Math.max(rightEdge - cardWidth, leftEdge),
minY: topEdge,
maxY: Math.max(bottomEdge - cardHeight, topEdge),
};
}
function tooltipLeftSafeEdge(stageRect) {
if (state.activeSpecialId || !elements.heroCopy || elements.heroCopy.hidden) {
return GLOBAL_TOOLTIP_SAFE_ZONE.inset;
}
const heroRect = elements.heroCopy.getBoundingClientRect();
if (!heroRect.width || heroRect.right <= stageRect.left || heroRect.left >= stageRect.right) {
return GLOBAL_TOOLTIP_SAFE_ZONE.inset;
}
const heroRight = heroRect.right - stageRect.left + GLOBAL_TOOLTIP_SAFE_ZONE.heroRightPadding;
const desiredLeft = Math.max(
GLOBAL_TOOLTIP_SAFE_ZONE.inset,
Math.min(heroRight, Math.max(GLOBAL_TOOLTIP_SAFE_ZONE.minLeft, stageRect.width * GLOBAL_TOOLTIP_SAFE_ZONE.leftRatio)),
);
return clamp(desiredLeft, GLOBAL_TOOLTIP_SAFE_ZONE.inset, stageRect.width - GLOBAL_TOOLTIP_SAFE_ZONE.inset);
}
function tooltipTopSafeEdge(stageRect) {
const kickerRect = elements.heroKicker?.getBoundingClientRect();
if (!kickerRect?.height) return GLOBAL_TOOLTIP_SAFE_ZONE.top;
return clamp(kickerRect.top - stageRect.top, GLOBAL_TOOLTIP_SAFE_ZONE.inset, GLOBAL_TOOLTIP_SAFE_ZONE.top);
}
function tooltipRightSafeEdge(stageRect) {
const fallbackEdge = stageRect.width - GLOBAL_TOOLTIP_SAFE_ZONE.right;
const controlEdges = [elements.specialBackButton, elements.themeToggle]
.map((element) => elementStageRightEdge(element, stageRect))
.filter((edge) => edge != null);
const rightCapsuleEdges = [...elements.specialLabelLayer.querySelectorAll(".special-capsule")]
.map((element) => elementStageRightEdge(element, stageRect))
.filter((edge) => edge != null && edge > stageRect.width * 0.55);
const visibleEdges = rightCapsuleEdges.length ? rightCapsuleEdges : controlEdges;
if (!visibleEdges.length) return fallbackEdge;
return Math.min(fallbackEdge, Math.max(...visibleEdges));
}
function tooltipBottomSafeEdge(stageRect) {
const fallbackEdge = stageRect.height - GLOBAL_TOOLTIP_SAFE_ZONE.bottom;
const footerEdges = [elements.musicSignature]
.map((element) => elementStageTopEdge(element, stageRect))
.filter((edge) => edge != null);
if (!footerEdges.length) return fallbackEdge;
return Math.min(fallbackEdge, Math.min(...footerEdges) - GLOBAL_TOOLTIP_SAFE_ZONE.footerTopPadding);
}
function syncTooltipSafeSize() {
const stageRect = elements.mapStage.getBoundingClientRect();
const rightEdge = state.bookCardMode === "detail"
? stageRect.width - GLOBAL_TOOLTIP_SAFE_ZONE.right
: tooltipRightSafeEdge(stageRect);
const { width: cardWidth } = tooltipCardSize();
const leftEdge = state.bookCardMode === "detail" ? detailTooltipLeftSafeEdge(stageRect, cardWidth, rightEdge) : tooltipLeftSafeEdge(stageRect);
const safeWidth = state.bookCardMode === "detail"
? detailTooltipSafeWidth(stageRect, cardWidth, rightEdge)
: Math.max(rightEdge - leftEdge, 220);
elements.cityTooltip.style.setProperty("--tooltip-safe-width", `${Math.floor(safeWidth)}px`);
}
function elementStageRightEdge(element, stageRect) {
if (!element || element.hidden) return null;
const rect = element.getBoundingClientRect();
if (!rect.width || !rect.height || rect.right <= stageRect.left || rect.left >= stageRect.right) return null;
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return null;
return rect.right - stageRect.left;
}
function elementStageTopEdge(element, stageRect) {
if (!element || element.hidden) return null;
const rect = element.getBoundingClientRect();
if (!rect.width || !rect.height || rect.bottom <= stageRect.top || rect.top >= stageRect.bottom) return null;
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return null;
return rect.top - stageRect.top;
}
function svgToStageFreePoint(x, y) {
const point = elements.mapSvg.createSVGPoint();
point.x = x;
point.y = y;
const transformed = point.matrixTransform(elements.mapSvg.getScreenCTM());
const stageRect = elements.mapStage.getBoundingClientRect();
return {
x: transformed.x - stageRect.left,
y: transformed.y - stageRect.top,
};
}
function projectionToStageGeometry(projection) {
const center = svgToStageFreePoint(projection.cx, projection.cy);
const radiusPoint = svgToStageFreePoint(projection.cx + projection.radius, projection.cy);
return {
...projection,
cx: center.x,
cy: center.y,
radius: Math.hypot(radiusPoint.x - center.x, radiusPoint.y - center.y),
};
}
function stageToSvgFreePoint(x, y) {
const point = elements.mapSvg.createSVGPoint();
const stageRect = elements.mapStage.getBoundingClientRect();
point.x = stageRect.left + x;
point.y = stageRect.top + y;
const matrix = elements.mapSvg.getScreenCTM();
if (!matrix) {
return {
x: (x / Math.max(stageRect.width, 1)) * VIEWBOX.width,
y: (y / Math.max(stageRect.height, 1)) * VIEWBOX.height,
};
}
const transformed = point.matrixTransform(matrix.inverse());
return {
x: transformed.x,
y: transformed.y,
};
}
function tooltipFocusCenterForCoordinate(coordinate, zoom) {
const [lon, lat] = coordinate;
const stageRect = elements.mapStage.getBoundingClientRect();
if (!stageRect.width || !stageRect.height) return [lon, lat];
const tooltipWidth = Math.max(tooltipCardSize().width || 0, stageRect.width < 760 ? 260 : 360);
const leftEdge = tooltipLeftSafeEdge(stageRect);
const rightEdge = tooltipRightSafeEdge(stageRect);
const markerReserve = stageRect.width < 760 ? 86 : 146;
const minAnchorX = Math.min(leftEdge + 72, stageRect.width - 72);
const maxAnchorX = Math.max(minAnchorX, rightEdge - tooltipWidth - markerReserve);
const desiredStageX = clamp(
stageRect.width * (stageRect.width < 760 ? 0.5 : 0.62),
minAnchorX,
maxAnchorX,
);
const desiredSvgPoint = stageToSvgFreePoint(desiredStageX, stageRect.height * 0.56);
const radius = BASE_RADIUS * zoom;
const cosLat = Math.cos(toRadians(lat));
if (!Number.isFinite(desiredSvgPoint.x) || !radius || Math.abs(cosLat) < 0.001) return [lon, lat];
const projectionCenterX = VIEWBOX.width * 0.84;
const desiredOffset = (desiredSvgPoint.x - projectionCenterX) / radius;
const delta = Math.asin(clamp(desiredOffset / cosLat, -0.92, 0.92));
return [normalizeLongitude(lon - toDegrees(delta)), lat];
}
function createProjection() {
const specialView = Boolean(state.activeSpecialId);
const projection = {
centerLon: state.center[0],
centerLat: state.center[1],
centerLonRad: toRadians(state.center[0]),
centerLatRad: toRadians(state.center[1]),
radius: BASE_RADIUS * state.zoom,
cx: VIEWBOX.width * (specialView ? 0.5 : 0.84),
cy: VIEWBOX.height * (specialView ? 0.5 : 0.56),
};
attachGeoPathProjection(projection);
return projection;
}
function attachGeoPathProjection(projection) {
const d3Geo = globalThis.d3;
if (!d3Geo?.geoOrthographic || !d3Geo?.geoPath) return;
const globeProjection = d3Geo.geoOrthographic()
.translate([projection.cx, projection.cy])
.scale(projection.radius)
.rotate([-projection.centerLon, -projection.centerLat])
.clipAngle(90)
.precision(0.6);
projection.geoProjection = globeProjection;
projection.geoPath = d3Geo.geoPath(globeProjection);
}
function animateView({ center, zoom, onComplete }) {
if (state.viewAnimationFrame) cancelAnimationFrame(state.viewAnimationFrame);
const startCenter = [...state.center];
const startZoom = state.zoom;
const targetCenter = [normalizeLongitude(center[0]), clamp(center[1], -72, 72)];
const startAt = performance.now();
const duration = 620;
const tick = (timestamp) => {
const progress = clamp((timestamp - startAt) / duration, 0, 1);
const eased = easeOutCubic(progress);
state.center = [
interpolateLongitude(startCenter[0], targetCenter[0], eased),
startCenter[1] + (targetCenter[1] - startCenter[1]) * eased,
];
state.zoom = startZoom + (zoom - startZoom) * eased;
render({ skipTooltipContent: true, freezeCityLabelLayout: true });
if (progress < 1) {
state.viewAnimationFrame = requestAnimationFrame(tick);
} else {
state.viewAnimationFrame = null;
render({ freezeCityLabelLayout: true });
onComplete?.();
}
};
state.viewAnimationFrame = requestAnimationFrame(tick);
}
function interpolateLongitude(start, end, progress) {
let delta = end - start;
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
return normalizeLongitude(start + delta * progress);
}
function easeOutCubic(value) {
return 1 - Math.pow(1 - value, 3);
}
function projectPoint(point, projection) {
const [lon, lat] = point;
const lambda = toRadians(lon);
const phi = toRadians(lat);
const delta = lambda - projection.centerLonRad;
const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
const cosCenter = Math.cos(projection.centerLatRad);
const sinCenter = Math.sin(projection.centerLatRad);
const visible = sinCenter * sinPhi + cosCenter * cosPhi * Math.cos(delta) >= -0.015;
return [
projection.cx + projection.radius * cosPhi * Math.sin(delta),
projection.cy - projection.radius * (cosCenter * sinPhi - sinCenter * cosPhi * Math.cos(delta)),
visible,
];
}
function featureToPath(feature, projection) {
const geometry = feature.geometry;
if (geometry.type === "LineString" || geometry.type === "MultiLineString") {
const lines = geometry.type === "LineString" ? [geometry.coordinates] : geometry.coordinates;
return lines.map((line) => lineToPath(line, projection)).filter(Boolean).join(" ");
}
const polygons = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.coordinates;
return polygons.map((polygon) => {
return polygon.map((ring) => lineToPath(ring, projection)).filter(Boolean).join(" ");
}).filter(Boolean).join(" ");
}
function featureEntryToPath(worldFeature, projection) {
const manualPath = featureToPathWithVisibility(worldFeature.feature, projection);
if (!manualPath.visiblePoints) {
return "";
}
if (!projection.geoPath || manualPath.visiblePoints >= manualPath.totalPoints) return manualPath.path;
const cacheKey = globePathCacheKey(worldFeature, projection);
const cachedPath = state.globePathCache.get(cacheKey);
if (cachedPath !== undefined) {
state.globePathCache.delete(cacheKey);
state.globePathCache.set(cacheKey, cachedPath);
return cachedPath;
}
const path = projection.geoPath(worldFeature.feature) || "";
cacheGlobePath(cacheKey, path);
return path;
}
function globePathCacheKey(worldFeature, projection) {
return [
worldFeature.pathKey || featureIsoA3(worldFeature.feature) || "feature",
quantizePathCacheValue(projection.centerLon, GLOBE_PATH_CACHE_CENTER_STEP),
quantizePathCacheValue(projection.centerLat, GLOBE_PATH_CACHE_CENTER_STEP),
quantizePathCacheValue(projection.radius, GLOBE_PATH_CACHE_RADIUS_STEP),
quantizePathCacheValue(projection.cx, GLOBE_PATH_CACHE_RADIUS_STEP),
quantizePathCacheValue(projection.cy, GLOBE_PATH_CACHE_RADIUS_STEP),
].join(":");
}
function quantizePathCacheValue(value, step) {
return Math.round(value / step) * step;
}
function cacheGlobePath(cacheKey, path) {
state.globePathCache.set(cacheKey, path);
while (state.globePathCache.size > GLOBE_PATH_CACHE_LIMIT) {
const firstKey = state.globePathCache.keys().next().value;
state.globePathCache.delete(firstKey);
}
}
function featureToPathWithVisibility(feature, projection) {
const geometry = feature.geometry;
const result = {
path: "",
visiblePoints: 0,
totalPoints: 0,
};
if (geometry.type === "LineString" || geometry.type === "MultiLineString") {
const lines = geometry.type === "LineString" ? [geometry.coordinates] : geometry.coordinates;
result.path = lines.map((line) => {
const lineResult = lineToPathWithVisibility(line, projection);
result.visiblePoints += lineResult.visiblePoints;
result.totalPoints += lineResult.totalPoints;
return lineResult.path;
}).filter(Boolean).join(" ");
return result;
}
const polygons = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.coordinates;
result.path = polygons.map((polygon) => {
return polygon.map((ring) => {
const ringResult = lineToPathWithVisibility(ring, projection);
result.visiblePoints += ringResult.visiblePoints;
result.totalPoints += ringResult.totalPoints;
return ringResult.path;
}).filter(Boolean).join(" ");
}).filter(Boolean).join(" ");
return result;
}
function lineToPathWithVisibility(line, projection) {
let path = "";
let drawing = false;
let visiblePoints = 0;
for (const point of line) {
const [x, y, visible] = projectPoint(point, projection);
if (!visible) {
drawing = false;
} else {
path += `${drawing ? "L" : "M"} ${x.toFixed(2)} ${y.toFixed(2)} `;
drawing = true;
visiblePoints += 1;
}
}
return {
path: path.trim(),
visiblePoints,
totalPoints: line.length,
};
}
function lineToPath(line, projection) {
let path = "";
let drawing = false;
for (const point of line) {
const [x, y, visible] = projectPoint(point, projection);
if (!visible) {
drawing = false;
continue;
}
path += `${drawing ? "L" : "M"} ${x.toFixed(2)} ${y.toFixed(2)} `;
drawing = true;
}
return path.trim();
}
function featureIsVisible(worldFeature, projection) {
if (projectPoint(worldFeature.centroid, projection)[2]) return true;
return worldFeature.flatCoordinates.some((point) => projectPoint(point, projection)[2]);
}
function calculateCentroid(points) {
const total = points.reduce((memo, point) => [memo[0] + point[0], memo[1] + point[1]], [0, 0]);
return [total[0] / points.length, total[1] / points.length];
}
function graticulePaths(projection) {
const paths = [];
for (let lon = -180; lon <= 180; lon += 20) {
const points = [];
for (let lat = -80; lat <= 80; lat += 3) points.push([lon, lat]);
paths.push(pointsToProjectedPath(points, projection));
}
for (let lat = -60; lat <= 80; lat += 20) {
const points = [];
for (let lon = -180; lon <= 180; lon += 3) points.push([lon, lat]);
paths.push(pointsToProjectedPath(points, projection));
}
return paths.filter(Boolean);
}
function pointsToProjectedPath(points, projection) {
let path = "";
let drawing = false;
for (const point of points) {
const [x, y, visible] = projectPoint(point, projection);
if (!visible) {
drawing = false;
continue;
}
path += `${drawing ? "L" : "M"} ${x.toFixed(2)} ${y.toFixed(2)} `;
drawing = true;
}
return path.trim();
}
function flattenCoordinates(coordinates) {
if (typeof coordinates[0] === "number") return [coordinates];
return coordinates.flatMap(flattenCoordinates);
}
function createSvg(tag, attributes = {}, text = "") {
const element = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [key, value] of Object.entries(attributes)) {
element.setAttribute(key, String(value));
}
if (text) element.textContent = text;
return element;
}
function closeTooltip() {
state.activePoint = null;
state.tooltipPoint = null;
state.bookCardMode = "ticket";
state.detailOrigin = null;
state.detailReturnTarget = null;
applyBookCardModeClass();
elements.cityTooltip.hidden = true;
closeSharePreview();
render();
}
function endDrag(event) {
const drag = state.dragging;
flushMapDragRender(drag);
if (drag?.moved) {
trackEvent("map_dragged", {
source_view: "global_map",
zoom: Number(state.zoom.toFixed(3)),
center_lon: Number(state.center[0].toFixed(3)),
center_lat: Number(state.center[1].toFixed(3)),
});
}
if (drag && !drag.moved && !drag.startedOnCityPoint && !event.target.closest(".city-html-marker, .special-capsule")) {
closeTooltip();
}
state.dragging = null;
elements.mapSvg.classList.remove("is-dragging");
if (drag?.moved) render({ skipTooltipContent: true, freezeCityLabelLayout: true });
}
function scheduleMapDragRender() {
const drag = state.dragging;
if (!drag || drag.renderFrame) return;
drag.renderFrame = requestAnimationFrame(() => {
drag.renderFrame = null;
if (state.dragging !== drag) return;
render({ skipTooltipContent: true, freezeCityLabelLayout: true });
});
}
function flushMapDragRender(drag) {
if (!drag?.renderFrame) return;
cancelAnimationFrame(drag.renderFrame);
drag.renderFrame = null;
if (drag.moved) render({ skipTooltipContent: true, freezeCityLabelLayout: true });
}
function startSubtleDrift() {
const tick = (timestamp) => {
if (!state.lastDriftAt) state.lastDriftAt = timestamp;
const delta = timestamp - state.lastDriftAt;
if (shouldPauseGlobeDrift()) {
state.lastDriftAt = timestamp;
} else if (!state.dragging && !state.hoverPoint && !state.activeSpecialId && !state.viewAnimationFrame && delta >= DRIFT_FRAME_MS) {
state.center = [normalizeLongitude(state.center[0] - Math.min(delta, 48) * DRIFT_DEGREES_PER_MS), state.center[1]];
state.lastDriftAt = timestamp;
render({ skipTooltipContent: true, freezeCityLabelLayout: true });
} else if (state.hoverPoint) {
state.lastDriftAt = timestamp;
syncGlobeTextures(createProjection());
}
state.driftFrame = requestAnimationFrame(tick);
};
state.driftFrame = requestAnimationFrame(tick);
}
function shouldPauseGlobeDrift() {
return Boolean(state.activePoint && elements.cityTooltip && !elements.cityTooltip.hidden);
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function normalizeLongitude(lon) {
let normalized = lon;
while (normalized > 180) normalized -= 360;
while (normalized < -180) normalized += 360;
return normalized;
}
function toRadians(degrees) {
return degrees * Math.PI / 180;
}
function toDegrees(radians) {
return radians * 180 / Math.PI;
}
applyTheme();
init();