mirror of
https://github.com/tiennm99/programming-fengshui.git
synced 2026-05-14 02:58:29 +00:00
b8502fd3fc
- Persist source/view/sort toggles via URL query params (?s=…&v=…&o=…). Short keys keep shared URLs readable; defaults are stripped to keep the bare URL clean. history.replaceState (no history pollution). Init seeds toggles from URL with clamp-to-default validation. - View class is applied BEFORE first render so renderGrid sees the correct show-all state on direct deep-link loads. - Add #source-note pill next to the source tag; shows "91 ngôn ngữ — palette riêng so với GitHub" when GitLab selected, hidden otherwise. Plus title attr on the source-toggle group with the full GitHub-vs-GitLab disparity. - In TIOBE view, hide cards with zero TIOBE-ranked langs (currently KIM has 0 of 19) — all-langs view still shows every Ngũ Hành card so the wheel stays intact. - source-tag firstChild text-node overwrite preserves the source-note span sibling. Closes phase-02 of plan 260428-1003-implement-all-remaining (items 2, 3, 4).
175 lines
6.3 KiB
JavaScript
175 lines
6.3 KiB
JavaScript
import { ELEMENTS, hexToHsl } from './classify-element.js';
|
|
|
|
// Relative luminance per WCAG 2.x — better than YIQ for mid-tones
|
|
// (e.g. saturated mid-greens / mid-reds where YIQ flips text color wrong).
|
|
function relLuminance(hex) {
|
|
const v = (c) => {
|
|
const x = parseInt(hex.slice(c, c + 2), 16) / 255;
|
|
return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
|
};
|
|
return 0.2126 * v(1) + 0.7152 * v(3) + 0.0722 * v(5);
|
|
}
|
|
|
|
function pickTextColor(hex) {
|
|
// Pick whichever of black/white scores higher WCAG contrast on this bg.
|
|
// Old 0.5 luminance threshold sent white onto mid-tones like #dea584 (Rust)
|
|
// → 2.14:1 ratio, well below AA. Computing both ratios fixes the long tail.
|
|
const L = relLuminance(hex);
|
|
const ratioBlack = (L + 0.05) / 0.05;
|
|
const ratioWhite = 1.05 / (L + 0.05);
|
|
return ratioBlack >= ratioWhite ? '#111' : '#fff';
|
|
}
|
|
|
|
function buildChip(name, color, { rank = null } = {}) {
|
|
const span = document.createElement('span');
|
|
span.className = 'chip' + (rank ? ' chip-tiobe' : ' chip-other');
|
|
span.setAttribute('role', 'listitem');
|
|
span.textContent = name;
|
|
if (color) {
|
|
span.style.background = color;
|
|
span.style.color = pickTextColor(color);
|
|
const hexLabel = color.toUpperCase();
|
|
const tooltip = rank ? `${name} · ${hexLabel} · TIOBE #${rank}` : `${name} · ${hexLabel}`;
|
|
span.title = tooltip;
|
|
span.setAttribute('aria-label', tooltip);
|
|
}
|
|
if (rank) span.dataset.rank = String(rank);
|
|
return span;
|
|
}
|
|
|
|
export function renderGrid(buckets, mountEl) {
|
|
if (!mountEl) return;
|
|
// In TIOBE view, skip cards with zero TIOBE-ranked langs — empty
|
|
// "0 ngôn ngữ" headings look broken. All-langs view always shows
|
|
// every element so the Ngũ Hành wheel stays intact.
|
|
const showAll = mountEl.closest('.elements')?.classList.contains('show-all') ?? false;
|
|
const fragment = document.createDocumentFragment();
|
|
for (const { key, label } of ELEMENTS) {
|
|
const langs = buckets[key] || [];
|
|
const tiobeCount = langs.filter((l) => l.rank).length;
|
|
if (!showAll && tiobeCount === 0) continue;
|
|
const card = document.createElement('article');
|
|
card.className = `card ${key}`;
|
|
const h3 = document.createElement('h3');
|
|
h3.textContent = label;
|
|
const count = document.createElement('small');
|
|
count.className = 'card-count';
|
|
count.textContent = tiobeCount
|
|
? `${tiobeCount} TIOBE · ${langs.length - tiobeCount} khác`
|
|
: `${langs.length} ngôn ngữ`;
|
|
const chips = document.createElement('div');
|
|
chips.className = 'chips';
|
|
chips.setAttribute('role', 'list');
|
|
chips.setAttribute('aria-label', `Ngôn ngữ thuộc ${label}`);
|
|
for (const { name, color, rank } of langs) chips.appendChild(buildChip(name, color, { rank }));
|
|
card.append(h3, count, chips);
|
|
fragment.appendChild(card);
|
|
}
|
|
mountEl.replaceChildren(fragment);
|
|
}
|
|
|
|
// Segmented control with ARIA radiogroup pattern + roving tabindex.
|
|
// Replaces previous tab/tablist roles (segmented controls are radios, not tabs —
|
|
// tabs require associated tabpanels which we don't have here).
|
|
export function mountSegmentedControl(mountEl, options, defaultKey, onChange, ariaLabel) {
|
|
if (!mountEl || !options?.length) return;
|
|
|
|
const group = document.createElement('div');
|
|
group.className = 'segmented';
|
|
group.setAttribute('role', 'radiogroup');
|
|
if (ariaLabel) group.setAttribute('aria-label', ariaLabel);
|
|
|
|
const buttons = options.map(({ key, label }) => {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.role = 'radio';
|
|
btn.className = 'segmented-btn';
|
|
btn.dataset.key = key;
|
|
btn.textContent = label;
|
|
btn.setAttribute('aria-checked', String(key === defaultKey));
|
|
btn.tabIndex = key === defaultKey ? 0 : -1;
|
|
btn.addEventListener('click', () => activate(key));
|
|
group.appendChild(btn);
|
|
return btn;
|
|
});
|
|
|
|
group.addEventListener('keydown', (e) => {
|
|
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
|
|
e.preventDefault();
|
|
const i = buttons.indexOf(document.activeElement);
|
|
let next;
|
|
if (e.key === 'Home') next = 0;
|
|
else if (e.key === 'End') next = buttons.length - 1;
|
|
else if (e.key === 'ArrowLeft') next = (i <= 0 ? buttons.length : i) - 1;
|
|
else next = (i + 1) % buttons.length;
|
|
buttons[next].focus();
|
|
activate(buttons[next].dataset.key);
|
|
});
|
|
|
|
function activate(key) {
|
|
for (const b of buttons) {
|
|
const active = b.dataset.key === key;
|
|
b.setAttribute('aria-checked', String(active));
|
|
b.tabIndex = active ? 0 : -1;
|
|
}
|
|
if (typeof onChange === 'function') onChange(key);
|
|
}
|
|
|
|
mountEl.replaceChildren(group);
|
|
}
|
|
|
|
export function renderError(message, mountEl) {
|
|
if (!mountEl) return;
|
|
const p = document.createElement('p');
|
|
p.className = 'render-error';
|
|
p.textContent = message;
|
|
mountEl.prepend(p);
|
|
}
|
|
|
|
const HUE_BOUNDARIES = [20, 40, 70, 200, 260];
|
|
|
|
export function isBorderline(hex) {
|
|
const { h, s, l } = hexToHsl(hex);
|
|
if (s >= 4 && s < 6) return true;
|
|
if (s < 5) return (l >= 18 && l <= 22) || (l >= 68 && l <= 72);
|
|
return HUE_BOUNDARIES.some((b) => Math.abs(h - b) <= 2);
|
|
}
|
|
|
|
export function renderDebugPanel({ skipped, borderline }, mountEl) {
|
|
if (!mountEl) return;
|
|
if (!skipped.length && !borderline.length) {
|
|
mountEl.hidden = true;
|
|
return;
|
|
}
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
const summary = document.createElement('summary');
|
|
summary.textContent = `Kiểm tra tự động (${skipped.length} skipped · ${borderline.length} borderline)`;
|
|
fragment.appendChild(summary);
|
|
|
|
if (skipped.length) {
|
|
const h4 = document.createElement('h4');
|
|
h4.textContent = 'Bỏ qua (không có màu)';
|
|
const list = document.createElement('div');
|
|
list.className = 'chips';
|
|
for (const { name } of skipped) list.appendChild(buildChip(name, null));
|
|
fragment.append(h4, list);
|
|
}
|
|
|
|
if (borderline.length) {
|
|
const h4 = document.createElement('h4');
|
|
h4.textContent = 'Trường hợp ranh giới';
|
|
const list = document.createElement('div');
|
|
list.className = 'chips';
|
|
for (const { name, color, element } of borderline) {
|
|
const chip = buildChip(`${name} → ${element}`, color);
|
|
chip.title = `${color} → ${element}`;
|
|
list.appendChild(chip);
|
|
}
|
|
fragment.append(h4, list);
|
|
}
|
|
|
|
mountEl.replaceChildren(fragment);
|
|
mountEl.hidden = false;
|
|
}
|