@qiwi/tech-radar
Version:
Fully automated tech-radar generator
234 lines (216 loc) • 9.57 kB
JavaScript
// Aurora client-side: shipped as a static asset, loaded with `defer`.
// Hover-card with description, blip↔legend cross-highlight, SPA-style nav
// between scope/timeline/entry pages with native View Transitions crossfade.
export const js = `(() => {
// ── Theme + chroma persistence ──────────────────────────────────
// Inline <script> in <head> applies stored prefs before paint to avoid
// FOUC; this block handles the toggle clicks and writes back.
const PREFS_KEY = 'aurora-prefs'
const readPrefs = () => {
try { return JSON.parse(localStorage.getItem(PREFS_KEY) || '{}') } catch { return {} }
}
const writePrefs = (p) => {
try { localStorage.setItem(PREFS_KEY, JSON.stringify(p)) } catch {}
}
const applyPrefs = () => {
const p = readPrefs()
document.documentElement.dataset.theme = p.theme || 'dark'
document.documentElement.dataset.chroma = p.chroma || 'color'
}
applyPrefs()
// Single-button cycle through all 4 (theme × chroma) combinations.
// Order keeps each step a one-axis flip, so the visual change is gradual:
// dark+color → dark+mono → light+mono → light+color → dark+color → …
const MODE_CYCLE = [
{ theme: 'dark', chroma: 'color' },
{ theme: 'dark', chroma: 'mono' },
{ theme: 'light', chroma: 'mono' },
{ theme: 'light', chroma: 'color' },
]
document.addEventListener('click', (e) => {
const btn = e.target.closest && e.target.closest('[data-toggle]')
if (!btn) return
if (btn.dataset.toggle !== 'mode') return
const p = readPrefs()
const cur = MODE_CYCLE.findIndex(
(m) => m.theme === (p.theme || 'dark') && m.chroma === (p.chroma || 'color'),
)
const next = MODE_CYCLE[(cur + 1) % MODE_CYCLE.length]
writePrefs(next)
applyPrefs()
})
const init = () => {
const card = document.getElementById('hoverCard')
if (!card) return
const elNum = card.querySelector('.hc-num')
const elName = card.querySelector('.hc-name')
const elRing = card.querySelector('.hc-ring')
const elMove = card.querySelector('.hc-move')
const elDesc = card.querySelector('.hc-desc')
const blips = document.querySelectorAll('.blip')
const legendItems = document.querySelectorAll('.legend-ring li')
const numToBlip = new Map()
const numToLegend = new Map()
blips.forEach(b => numToBlip.set(b.dataset.num, b))
legendItems.forEach(li => {
const num = li.querySelector('.li-num')?.textContent
if (num) numToLegend.set(num, li)
})
const positionCard = (ev) => {
if (!ev) return
const margin = 16
const w = card.offsetWidth || 320
const h = card.offsetHeight || 100
let x = ev.clientX + margin
let y = ev.clientY + margin
if (x + w + margin > innerWidth) x = ev.clientX - w - margin
if (y + h + margin > innerHeight) y = ev.clientY - h - margin
card.style.left = Math.max(margin, x) + 'px'
card.style.top = Math.max(margin, y) + 'px'
}
const populate = (blip) => {
elNum.textContent = '#' + blip.dataset.num
elName.textContent = blip.dataset.name
elRing.textContent = blip.dataset.ring
const moved = +blip.dataset.moved || 0
elMove.textContent = moved > 0 ? 'moved up' : moved < 0 ? 'moved down' : ''
elDesc.textContent = blip.dataset.desc || ''
}
const showCard = (blip, ev) => {
populate(blip)
positionCard(ev)
card.classList.add('is-shown')
}
const hideCard = () => card.classList.remove('is-shown')
const setActive = (num, on) => {
numToBlip.get(num)?.classList.toggle('is-active', on)
numToLegend.get(num)?.classList.toggle('is-active', on)
}
blips.forEach(b => {
const link = b.parentElement
link.addEventListener('pointerenter', e => { showCard(b, e); setActive(b.dataset.num, true) })
link.addEventListener('pointermove', positionCard)
link.addEventListener('pointerleave', () => { hideCard(); setActive(b.dataset.num, false) })
link.addEventListener('focus', () => {
const r = link.getBoundingClientRect()
showCard(b, { clientX: r.left + r.width/2, clientY: r.top })
setActive(b.dataset.num, true)
})
link.addEventListener('blur', () => { hideCard(); setActive(b.dataset.num, false) })
})
// Timeline preview — hovering a dot mirrors its date into the top-right
// meta. Pointerleave restores the page's own date.
const meta = document.getElementById('metaDate')
document.querySelectorAll('.tl-dot').forEach(dot => {
const date = dot.dataset.date
if (!date || !meta) return
dot.addEventListener('pointerenter', () => { meta.textContent = date })
dot.addEventListener('pointerleave', () => { meta.textContent = meta.dataset.default || '' })
dot.addEventListener('focus', () => { meta.textContent = date })
dot.addEventListener('blur', () => { meta.textContent = meta.dataset.default || '' })
})
legendItems.forEach(li => {
const num = li.querySelector('.li-num')?.textContent
if (!num) return
li.addEventListener('pointerenter', () => setActive(num, true))
li.addEventListener('pointerleave', () => setActive(num, false))
li.addEventListener('click', () => {
const blip = numToBlip.get(num)
const link = blip?.parentElement
const href = link?.getAttribute('href')
if (href) navigate(new URL(href, location.href).href)
})
})
// Entry-page back arrow — prefer history.back() when we have a real prior
// entry in the SPA stack. Otherwise fall through to the <a href="..."> path
// which the global click listener turns into an SPA navigation. We must
// stopPropagation so the document-level handler doesn't double-fire.
const back = document.querySelector('.entry-title .back')
if (back) {
back.addEventListener('click', (e) => {
if (history.length > 1) {
e.preventDefault()
e.stopPropagation()
history.back()
}
})
}
}
// ── SPA navigation ──────────────────────────────────────────────
// Same-origin clicks are intercepted: fetch the target, swap <body>,
// re-bind handlers. View Transitions API gives us a native crossfade
// when the browser supports it (Chrome/Edge/Safari TP).
const SPA_CACHE = new Map()
const fetchPage = async (url) => {
if (SPA_CACHE.has(url)) return SPA_CACHE.get(url)
const html = await fetch(url, { credentials: 'same-origin' }).then(r => r.text())
SPA_CACHE.set(url, html)
return html
}
const followMetaRefresh = (doc) => {
const m = doc.querySelector('meta[http-equiv="refresh" i]')
if (!m) return null
const match = /url=([^;]+)/i.exec(m.getAttribute('content') || '')
return match ? match[1].trim() : null
}
// \`loadedUrl\` is the URL whose body is currently mounted — independent of
// location.href which the click handler bumps via pushState before navigate.
let loadedUrl = location.href
const navigate = async (url, depth = 0) => {
if (url === loadedUrl && depth === 0) return
if (depth > 3) { location.href = url; return }
let html
try { html = await fetchPage(url) } catch { location.href = url; return }
const doc = new DOMParser().parseFromString(html, 'text/html')
// Server-rendered redirect pages (\`<scope>/index.html\`) — follow chain.
const next = followMetaRefresh(doc)
if (next) {
const nextUrl = new URL(next, url).href
history.replaceState(null, '', nextUrl)
return navigate(nextUrl, depth + 1)
}
const swap = () => {
document.title = doc.title
// Each radar emits its own per-sector palette as <style id="radar-palette">
// because sector ids/hues differ across snapshots (e.g. 6 sectors vs 5).
// If we don't swap it here, going from a smaller-N radar to a larger-N
// one leaves the new sectors with undefined CSS vars → black wash.
const oldPalette = document.getElementById('radar-palette')
const newPalette = doc.getElementById('radar-palette')
if (oldPalette && newPalette) {
oldPalette.replaceWith(newPalette)
} else if (newPalette) {
document.head.append(newPalette)
}
// Replace body contents (script in <head> stays alive — IIFE already running).
document.body.replaceChildren(...doc.body.childNodes)
window.scrollTo(0, 0)
init()
loadedUrl = url
}
if (document.startViewTransition) {
const t = document.startViewTransition(swap)
try { await t.finished } catch {}
} else {
swap()
}
}
document.addEventListener('click', (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return
const link = e.target.closest && e.target.closest('a[href]')
if (!link) return
const href = link.getAttribute('href') || ''
if (!href || href.startsWith('#') || href.startsWith('mailto:')) return
let target
try { target = new URL(href, location.href) } catch { return }
if (target.origin !== location.origin) return
e.preventDefault()
history.pushState(null, '', target.href)
navigate(target.href)
})
window.addEventListener('popstate', () => {
navigate(location.href).catch(() => location.reload())
})
init()
})()
`