← back to Crazy News Channel

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 &rarr;</a>
  <a class="home" href="reference/index.html">Style Reference: Classic Editorial Ink &rarr;</a>
  <a class="home" href="../index.html">&larr; 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&rarr;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 &rarr;</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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>