← back to Crazy News Channel Shadowman
cartoons/index.html
399 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>P24 Political Cartoon Desk — PANDEMONIUM-24</title>
<meta name="description" content="The P24 Political Cartoon Desk archive — original invented satire strips, updated daily." />
<link rel="stylesheet" href="../assets/style.css" />
</head>
<body data-mood="all">
<header class="chrome">
<span class="logo-badge" aria-hidden="true">P24</span>
<h1>Political Cartoon Desk</h1>
<a class="home" href="shadow-man.html">Shadow Man Gallery →</a>
<a class="home" href="reference/index.html">Style Reference: Classic Editorial Ink →</a>
<a class="home" href="../index.html">← Back to PANDEMONIUM-24</a>
</header>
<main>
<p class="dek">Original, invented editorial satire — bureaucracy, committees, and the occasional pigeon director. New strips added daily. All persons, departments, and forms are fictional.</p>
<nav class="category-filter" aria-label="Category and mood filter">
<fieldset>
<legend class="visually-hidden">Choose a cartoon category — changes the page's color mood</legend>
<div class="chip-row" id="chipRow">
<span class="chip-item">
<input type="radio" name="cartoonCategory" id="cat-all" value="all" checked />
<label for="cat-all">All Categories</label>
</span>
<span class="chip-item">
<input type="radio" name="cartoonCategory" id="cat-politics" value="politics" />
<label for="cat-politics">Politics</label>
</span>
<span class="chip-item">
<input type="radio" name="cartoonCategory" id="cat-weather" value="weather" />
<label for="cat-weather">Weather</label>
</span>
<span class="chip-item">
<input type="radio" name="cartoonCategory" id="cat-scitech" value="scitech" />
<label for="cat-scitech">Sci-Tech</label>
</span>
<span class="chip-item">
<input type="radio" name="cartoonCategory" id="cat-sports" value="sports" />
<label for="cat-sports">Sports</label>
</span>
<span class="chip-item">
<input type="radio" name="cartoonCategory" id="cat-entertainment" value="entertainment" />
<label for="cat-entertainment">Entertainment</label>
</span>
<span class="chip-item">
<input type="radio" name="cartoonCategory" id="cat-business" value="business" />
<label for="cat-business">Business</label>
</span>
<span class="chip-item">
<input type="radio" name="cartoonCategory" id="cat-uncategorized" value="uncategorized" />
<label for="cat-uncategorized">Uncategorized</label>
</span>
</div>
</fieldset>
</nav>
<!-- Active tag filter indicator (#tag=<tag> route) — "Tagged: X ✕". -->
<div id="tagFilterBar" class="tag-filter-bar" hidden>
<span class="tag-filter-label">Tagged: <strong id="tagFilterName"></strong></span>
<a href="#" id="tagFilterClear" class="tag-filter-clear">✕ Clear</a>
</div>
<!-- Tag cloud — top tags with counts, click to filter. -->
<div id="tagCloud" class="tag-cloud" aria-label="Browse by tag"></div>
<div class="controls" role="group" aria-label="Sort and display density">
<label for="sortSel">Sort
<select id="sortSel">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="title">Title A→Z</option>
</select>
</label>
<label for="density" id="densityLabel">Density
<input type="range" id="density" min="220" max="420" step="20" value="300" aria-valuetext="card width">
</label>
<div class="view-toggle" role="group" aria-label="View mode">
<button type="button" id="viewGridBtn" aria-pressed="true">Grid</button>
<button type="button" id="viewListBtn" aria-pressed="false">List</button>
</div>
<span id="count"></span>
</div>
<div class="grid" id="grid" role="list"></div>
<div class="empty" id="empty" hidden>
<p id="emptyMsg">No cartoons on file yet — the Desk is between assignments.</p>
<a href="../index.html">Read the rest of P24 →</a>
</div>
</main>
<footer>All cartoons are invented satire. No actual bureaucrats were inconvenienced. Part of PANDEMONIUM-24.</footer>
<script src="../stories-data.js"></script>
<script src="manifest.js"></script>
<script>
(function(){
const grid = document.getElementById('grid');
const empty = document.getElementById('empty');
const emptyMsg = document.getElementById('emptyMsg');
const sortSel = document.getElementById('sortSel');
const density = document.getElementById('density');
const densityLabel = document.getElementById('densityLabel');
const viewGridBtn = document.getElementById('viewGridBtn');
const viewListBtn = document.getElementById('viewListBtn');
const count = document.getElementById('count');
const chipRow = document.getElementById('chipRow');
const LS_SORT = 'p24cartoons.sort', LS_DENSITY = 'p24cartoons.density', LS_VIEW = 'p24cartoons.viewmode';
const LS_CATEGORY = 'p24cartoons.category';
function safeGet(k, d){ try { return localStorage.getItem(k) ?? d; } catch { return d; } }
function safeSet(k, v){ try { localStorage.setItem(k, v); } catch {} }
sortSel.value = safeGet(LS_SORT, 'newest');
density.value = safeGet(LS_DENSITY, '300');
document.documentElement.style.setProperty('--card-w', density.value + 'px');
let viewMode = safeGet(LS_VIEW, 'grid');
if (viewMode !== 'grid' && viewMode !== 'list') viewMode = 'grid';
function applyViewMode(){
grid.classList.toggle('list-mode', viewMode === 'list');
densityLabel.hidden = viewMode === 'list';
viewGridBtn.setAttribute('aria-pressed', String(viewMode === 'grid'));
viewListBtn.setAttribute('aria-pressed', String(viewMode === 'list'));
}
applyViewMode();
function setViewMode(mode){
if (mode !== 'grid' && mode !== 'list') return;
viewMode = mode;
safeSet(LS_VIEW, viewMode);
applyViewMode();
}
viewGridBtn.addEventListener('click', () => setViewMode('grid'));
viewListBtn.addEventListener('click', () => setViewMode('list'));
function fmtDate(iso){
try { return new Date(iso).toLocaleDateString(undefined, { year:'numeric', month:'short', day:'numeric' }); }
catch { return iso; }
}
/* ---------- Category classification ---------- */
// Same 8 keys as ../index.html's mood system ("all" plus these 7).
const CATS = [
{ key: 'all', label: 'All Categories' },
{ key: 'politics', label: 'Politics' },
{ key: 'weather', label: 'Weather' },
{ key: 'scitech', label: 'Sci-Tech' },
{ key: 'sports', label: 'Sports' },
{ key: 'entertainment', label: 'Entertainment' },
{ key: 'business', label: 'Business' },
{ key: 'uncategorized', label: 'Uncategorized' },
];
const CATEGORY_KEYS = CATS.filter((c) => c.key !== 'all').map((c) => c.key);
const CATEGORY_LABEL = Object.fromEntries(CATS.map((c) => [c.key, c.label]));
function isValidCategory(key){ return CATS.some((c) => c.key === key); }
// Looks a cartoon's linked story up by id across every loaded story data
// source (currently just ../stories-data.js's window.P24_EXTRA_STORIES —
// ../real-news-data.js is not loaded here because none of the current
// manifest.js story_id values resolve there; add it the same way if a
// future cartoon links a real-news story).
function storyCategoryById(id){
if (!id) return null;
const sources = [];
if (Array.isArray(window.P24_EXTRA_STORIES)) sources.push(window.P24_EXTRA_STORIES);
if (Array.isArray(window.P24_REAL_STORIES)) sources.push(window.P24_REAL_STORIES);
for (const arr of sources) {
const found = arr.find((s) => s && s.id === id);
if (found && found.category) return found.category;
}
return null;
}
// Category of a cartoon, in priority order:
// 1. c.section, if it's one of the 7 category keys
// 2. c.story_id, looked up against the story data sources above
// 3. c.category === "Political Cartoon" -> "politics"
// 4. "uncategorized"
function categoryOf(c){
if (c.section && CATEGORY_KEYS.includes(c.section)) return c.section;
if (c.story_id) {
const found = storyCategoryById(c.story_id);
if (found && CATEGORY_KEYS.includes(found)) return found;
}
if (c.category === 'Political Cartoon') return 'politics';
return 'uncategorized';
}
function getCategoryFromHash(){
const m = location.hash.match(/^#\/category\/(.+)$/);
if (!m) return null;
try { return decodeURIComponent(m[1]); } catch { return null; }
}
/* ---------- Tags (TK-12165) ----------
A #tag=<tag> route spans every category, same model as ../index.html —
it takes over from the category filter rather than combining with it. */
const tagFilterBar = document.getElementById('tagFilterBar');
const tagFilterName = document.getElementById('tagFilterName');
const tagFilterClear = document.getElementById('tagFilterClear');
const tagCloud = document.getElementById('tagCloud');
function normalizeTag(t){ return String(t == null ? '' : t).trim().toLowerCase(); }
function tagHref(tag){ return `#tag=${encodeURIComponent(normalizeTag(tag))}`; }
function getTagFromHash(){
const m = location.hash.match(/^#tag=(.+)$/);
if (!m) return null;
try { return normalizeTag(decodeURIComponent(m[1])); } catch { return null; }
}
let currentTag = getTagFromHash();
function setTag(tag, opts){
opts = opts || {};
currentTag = tag || null;
if (!opts.fromHash) {
const target = currentTag ? tagHref(currentTag) : '';
if (location.hash !== target) location.hash = target;
}
renderTagUI();
render();
}
function tagCounts(){
const counts = new Map();
for (const c of cartoons) {
if (!Array.isArray(c.tags)) continue;
for (const t of c.tags) {
const key = normalizeTag(t);
if (!key) continue;
const entry = counts.get(key) || { label: t, count: 0 };
entry.count++;
counts.set(key, entry);
}
}
return counts;
}
function tagChipsHTML(tags){
if (!Array.isArray(tags) || !tags.length) return '';
return '<ul class="tag-chips card-tags" aria-label="Tags">' +
tags.map((t) => `<li><a href="${tagHref(t)}" class="tag-chip${normalizeTag(t) === currentTag ? ' active' : ''}">${String(t).replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}</a></li>`).join('') +
'</ul>';
}
function renderTagUI(){
if (currentTag) {
tagFilterBar.hidden = false;
tagFilterName.textContent = currentTag;
tagCloud.hidden = true;
tagCloud.innerHTML = '';
return;
}
tagFilterBar.hidden = true;
tagCloud.hidden = false;
const counts = [...tagCounts().values()]
.filter((e) => e.count > 1)
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
.slice(0, 20);
tagCloud.innerHTML = counts.length ? (
'<span class="tag-cloud-label">Browse by tag:</span><ul class="tag-cloud-list">' +
counts.map((e) => `<li><a href="${tagHref(e.label)}" class="tag-chip">${e.label} <span class="tag-count">${e.count}</span></a></li>`).join('') +
'</ul>'
) : '';
}
tagFilterClear.addEventListener('click', (e) => { e.preventDefault(); setTag(null); });
let currentCategory = getCategoryFromHash();
if (!currentCategory || !isValidCategory(currentCategory)) {
currentCategory = safeGet(LS_CATEGORY, 'all');
}
if (!isValidCategory(currentCategory)) currentCategory = 'all';
function syncChipRadio(){
const input = document.getElementById(`cat-${currentCategory}`);
if (input) input.checked = true;
}
function applyMood(){
document.body.dataset.mood = currentCategory;
}
function setCategory(key, opts){
opts = opts || {};
if (!isValidCategory(key)) return;
currentCategory = key;
applyMood();
syncChipRadio();
safeSet(LS_CATEGORY, currentCategory);
if (!opts.fromHash) {
const target = currentCategory === 'all' ? '' : `#/category/${currentCategory}`;
if (location.hash !== target) location.hash = target;
}
render();
}
chipRow.addEventListener('change', (e) => {
const t = e.target;
if (t && t.name === 'cartoonCategory') setCategory(t.value);
});
window.addEventListener('hashchange', () => {
const tagFromHash = getTagFromHash();
if (tagFromHash !== currentTag) {
setTag(tagFromHash, { fromHash: true });
return; // tag route spans categories — don't also reconcile the category route below
}
const fromHash = getCategoryFromHash();
if (fromHash && isValidCategory(fromHash) && fromHash !== currentCategory) {
setCategory(fromHash, { fromHash: true });
} else if (!fromHash && currentCategory !== 'all' && location.hash === '') {
setCategory('all', { fromHash: true });
}
});
// Apply the resolved initial category before first render.
applyMood();
syncChipRadio();
let cartoons = [];
function render(){
// A tag filter spans every category (same model as ../index.html), so
// it replaces the category filter rather than combining with it.
let filtered = currentTag
? cartoons.filter((c) => Array.isArray(c.tags) && c.tags.some((t) => normalizeTag(t) === currentTag))
: (currentCategory === 'all' ? cartoons : cartoons.filter((c) => categoryOf(c) === currentCategory));
const sorted = [...filtered];
if (sortSel.value === 'newest') sorted.sort((a,b)=> (b.created_at||'').localeCompare(a.created_at||''));
else if (sortSel.value === 'oldest') sorted.sort((a,b)=> (a.created_at||'').localeCompare(b.created_at||''));
else if (sortSel.value === 'title') sorted.sort((a,b)=> a.title.localeCompare(b.title));
grid.innerHTML = '';
empty.hidden = sorted.length !== 0;
emptyMsg.textContent = currentTag
? `No cartoons tagged "${currentTag}."`
: (currentCategory === 'all'
? 'No cartoons on file yet — the Desk is between assignments.'
: `No cartoons in ${CATEGORY_LABEL[currentCategory] || currentCategory} yet.`);
count.textContent = sorted.length ? `${sorted.length} strip${sorted.length===1?'':'s'} on file` : '';
for (const c of sorted) {
const cat = categoryOf(c);
// The whole card is a link to the strip file — tag chips must NOT
// nest inside that anchor (nested <a> is invalid HTML and click
// behavior on the inner link is unreliable across engines), so the
// tags row is a SIBLING of the card anchor inside a wrapper div.
const wrap = document.createElement('div');
wrap.className = 'card-wrap'; wrap.dataset.cat = cat;
const a = document.createElement('a');
a.className = 'card'; a.href = c.file; a.role = 'listitem';
a.dataset.cat = cat;
const thumb = document.createElement('div');
thumb.className = 'thumb'; thumb.setAttribute('aria-hidden', 'true');
if (c.thumb) thumb.style.backgroundImage = `url('${c.thumb.replace(/['\\]/g, '')}')`;
else thumb.textContent = '\u{1F58B}\uFE0F';
const body = document.createElement('div'); body.className = 'body';
const badge = document.createElement('span'); badge.className = 'cat-badge';
badge.textContent = CATEGORY_LABEL[cat] || cat;
const title = document.createElement('div'); title.className = 'title'; title.textContent = c.title || '';
const meta = document.createElement('div'); meta.className = 'meta';
meta.title = c.created_at || '';
meta.textContent = (c.created_at ? fmtDate(c.created_at) : '') + (c.category ? ' \u00b7 ' + c.category : '');
const blurb = document.createElement('div'); blurb.className = 'blurb'; blurb.textContent = c.blurb || '';
body.append(badge, title, meta, blurb);
a.append(thumb, body);
wrap.appendChild(a);
if (Array.isArray(c.tags) && c.tags.length) {
const tagsRow = document.createElement('div');
tagsRow.className = 'card-tags-row';
tagsRow.innerHTML = tagChipsHTML(c.tags);
wrap.appendChild(tagsRow);
}
grid.appendChild(wrap);
}
}
sortSel.addEventListener('change', () => { safeSet(LS_SORT, sortSel.value); render(); });
density.addEventListener('input', () => {
document.documentElement.style.setProperty('--card-w', density.value + 'px');
safeSet(LS_DENSITY, density.value);
});
cartoons = window.P24_CARTOONS || [];
renderTagUI();
render();
})();
</script>
</body>
</html>