[html]
<article class="lore-article chronicle-page" id="chronicle-app">
<header class="chronicle-plate" id="chronicle-plate">
<span class="chronicle-art-note">Место для иллюстрации</span>
<span class="lore-badge">Общая хронология</span>
<h1>Хроники Материка</h1>
<p class="lore-tagline">От исхода пришлых до нынешнего безмолвного противостояния</p>
</header>
<!-- инлайновый скрипт ниже вставляет сразу после плашки N радио
.chronicle-era-input и nav.lore-tabs.chronicle-eras, а в конец
#chronicle-app — div.lore-panels.chronicle-panels, всё из JSON, чтобы
контент и разметка не расходились -->
<div class="chronicle-filter">
<span class="chronicle-filter-label">Показать:</span>
<div class="chronicle-filter-chips" id="chronicle-filter-chips" role="group" aria-label="Фильтр по стране, Империи в целом или общим событиям"></div>
</div>
<p class="chronicle-intro">Единого летоисчисления материк не знал до основания престола — годы до него провинции считали каждая по-своему, и здесь они даны условно, «от основания» (И.Э. — Имперская эра). Эта хроника сводит две вещи, которые обычно рассказывают порознь: то, что происходило со всем материком разом, и то, что происходило в землях, которые сегодня зовутся Латериумом, Альсаром и Тянься. Фильтр общий для всех эпох и сохраняется при переключении.</p>
</article>
<script>
// Данные хроники — отдельный JSON-файл (articles/timeline-data.json,
// залитый на forumstatic.ru тем же способом, что и другие внешние ассеты
// форума), а не разметка в этом файле — так фильтр, полоса эпох и сам
// таймлайн строятся из одного источника и правятся в одном месте.
const CHRONICLE_DATA_URL = 'https://forumstatic.ru/files/001c/c7/56/26482.json';
// цвет области берётся из --tl-<id> в colors.css по id из JSON — новая
// область в JSON требует только новой переменной, не новых правил CSS
const scopeAccent = (scopeId) => `--item-accent: var(--tl-${scopeId})`;
const renderChip = ({ id, label }) => `
<input class="chronicle-filter-input" type="checkbox" id="filter-${id}" data-scope="${id}">
<label class="chronicle-chip" data-scope="${id}" for="filter-${id}" style="${scopeAccent(id)}">${label}</label>
`;
// год — отдельная колонка слева от оси (на телефоне — строкой над
// заголовком), страна/Империя — безрамочная подпись под заголовком
const renderEvent = (scopeLabels) => ({ year, scope, title, text }) => `
<li class="chronicle-item" data-scope="${scope}" style="${scopeAccent(scope)}">
<span class="chronicle-year">${year}</span>
<h4>${title}</h4>
<span class="chronicle-scope-tag">${scopeLabels.get(scope) ?? scope}</span>
<p>${text}</p>
</li>
`;
const renderEraInput = ({ id }, index) => `
<input class="lore-tab-input chronicle-era-input" type="radio" name="chronicle-era" id="era-${id}" ${index === 0 ? 'checked' : ''}>
`;
const renderEraCard = ({ id, name, range }) => `
<label class="lore-tab chronicle-era-card" for="era-${id}">
<span class="chronicle-era-name">${name}</span>
<span class="chronicle-era-range">${range}</span>
</label>
`;
// oneLiner эпохи — не в полосе эпох (пять колонок его не вмещают), а
// вводной строкой открытой панели
const renderEraPanel = (scopeLabels) => ({ id, oneLiner, intro, events }) => `
<section class="lore-panel chronicle-detail" data-panel="${id}">
<p class="chronicle-detail-lead">${oneLiner}</p>
<p>${intro}</p>
<ol class="chronicle-timeline">${events.map(renderEvent(scopeLabels)).join('')}</ol>
<p class="chronicle-empty" hidden>Ни одно событие этой эпохи не подходит под выбранный фильтр.</p>
</section>
`;
// радио должны стоять раньше и полосы эпох, и панелей (общий сосед для ~)
const renderEras = ({ periods, scopes }) => {
const scopeLabels = new Map(scopes.map(({ id, label }) => [id, label]));
document.getElementById('chronicle-plate').insertAdjacentHTML('afterend', `
${periods.map(renderEraInput).join('')}
<nav class="lore-tabs chronicle-eras" aria-label="Эпохи материка">${periods.map(renderEraCard).join('')}</nav>
`);
document.getElementById('chronicle-app').insertAdjacentHTML('beforeend', `
<div class="lore-panels chronicle-panels">${periods.map(renderEraPanel(scopeLabels)).join('')}</div>
`);
};
const renderFilterChips = ({ scopes }) => {
document.getElementById('chronicle-filter-chips').insertAdjacentHTML('beforeend', `
<input class="chronicle-filter-input" type="checkbox" id="filter-all" checked>
<label class="chronicle-chip" for="filter-all">Всё</label>
${scopes.map(renderChip).join('')}
`);
};
const scopeCheckboxes = () => Array.from(document.querySelectorAll('.chronicle-filter-input[data-scope]'));
// пустой список = фильтр не сужает ничего, показываем все события
const applyFilter = () => {
const active = scopeCheckboxes().filter((input) => input.checked).map((input) => input.dataset.scope);
document.querySelectorAll('.chronicle-detail').forEach((panel) => {
const items = Array.from(panel.querySelectorAll('.chronicle-item'));
const shown = items.filter((item) => !active.length || active.includes(item.dataset.scope));
items.forEach((item) => { item.hidden = !shown.includes(item); });
panel.querySelector('.chronicle-empty').hidden = shown.length > 0;
});
};
const onFilterChange = (target) => {
const allCheckbox = document.getElementById('filter-all');
if (target === allCheckbox) {
if (target.checked) scopeCheckboxes().forEach((input) => { input.checked = false; });
} else {
if (target.checked) allCheckbox.checked = false;
if (!scopeCheckboxes().some((input) => input.checked)) allCheckbox.checked = true;
}
applyFilter();
};
// на телефоне полоса эпох прокручивается вбок — выбранная эпоха не должна
// оставаться за краем экрана
const onEraChange = (target) => {
target.labels[0].scrollIntoView({ block: 'nearest', inline: 'nearest' });
};
// один делегированный обработчик на всю статью: и радио эпох, и чекбоксы
// фильтра вставляются скриптом, а не стоят в разметке заранее
const onChange = ({ target }) => {
if (target.matches('.chronicle-filter-input')) onFilterChange(target);
else if (target.matches('.chronicle-era-input')) onEraChange(target);
};
const initChronicle = (data) => {
renderEras(data);
renderFilterChips(data);
document.getElementById('chronicle-app').addEventListener('change', onChange);
};
fetch(CHRONICLE_DATA_URL)
.then((response) => response.json())
.then(initChronicle);
</script>
[/html][hideprofile]
