Add turnover, project, retention, and year comparison pages from the supplied workbook, protected by named admin access. Include the local chart gallery, grouped monthly hover, smooth comparison toggle, and monthly income minus cost series. Require named PocketBase sign-in in every environment and remove the shared PIN bypass. Update access documentation and business-route test fixtures.
106 lines
35 KiB
HTML
106 lines
35 KiB
HTML
<!doctype html>
|
||
<html lang="en" data-theme="dark">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Management · Chart Gallery</title>
|
||
<style>
|
||
:root{color-scheme:dark;--bg:#111722;--card:#1b2330;--card-hover:#202a39;--line:#334052;--ink:#eff2f7;--muted:#98a7bc;--gold:#f4ba5e;--gold-soft:#f4ba5e20;--teal:#65cbb4;--blue:#879fbf;--red:#f08191;--shadow:#0004}
|
||
:root[data-theme=light]{color-scheme:light;--bg:#eeeae2;--card:#fffdf8;--card-hover:#f6f2e9;--line:#ddd6c9;--ink:#263345;--muted:#728094;--gold:#bd7921;--gold-soft:#bd792118;--teal:#218a73;--blue:#8095b1;--red:#c95870;--shadow:#5a493915}
|
||
*{box-sizing:border-box}body{margin:0;font-family:"Avenir Next",Avenir,"Segoe UI",sans-serif;background:var(--bg);color:var(--ink);font-size:14px}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,a:focus-visible,.plot:focus-visible{outline:2px solid var(--gold);outline-offset:4px}button{color:inherit}a{color:inherit}header{position:sticky;top:0;z-index:10;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px 44px;background:var(--bg);border-bottom:1px solid var(--line)}.identity{display:flex;align-items:center;gap:13px}.mark{display:grid;grid-template-columns:repeat(2,9px);gap:3px;transform:rotate(-12deg)}.mark i{width:9px;height:9px;background:var(--gold);border-radius:1px}.brand{font-size:11px;font-weight:650;letter-spacing:.15em;text-transform:uppercase}.brand small{display:block;color:var(--muted);font-size:9px;letter-spacing:.11em;margin-top:5px}.header-actions{display:flex;align-items:center;gap:12px}.quiet{border:1px solid var(--line);border-radius:7px;background:transparent;padding:9px 13px;font-size:11px}.quiet:hover{background:var(--card-hover)}.theme{width:37px;height:37px;display:grid;place-items:center;padding:0}.theme svg{width:17px;height:17px}.wrap{max-width:1550px;padding:0 44px 60px;margin:auto}.intro{display:grid;grid-template-columns:1.7fr 1fr;gap:40px;padding:54px 0 36px}.eyebrow{text-transform:uppercase;letter-spacing:.17em;font-size:10px;color:var(--gold);font-weight:650;margin:0 0 16px}h1{font-family:"Palatino Linotype",Palatino,Georgia,serif;font-size:clamp(38px,4.7vw,69px);font-weight:400;letter-spacing:-.055em;line-height:1.05;margin:0 0 20px}h1 em{font-style:italic;color:var(--gold)}.intro-copy{font-size:14px;color:var(--muted);max-width:630px;line-height:1.8;margin:0}.intro-aside{align-self:end;border-left:1px solid var(--line);padding-left:28px;display:grid;grid-template-columns:1fr 1fr;gap:20px}.intro-aside strong{display:block;font-size:31px;font-weight:500;letter-spacing:-.055em}.intro-aside span{color:var(--muted);font-size:11px;line-height:1.8}.intro-aside p{grid-column:1/-1;margin:0;color:var(--muted);font-size:11px;line-height:1.8}.toolbar{display:flex;flex-wrap:wrap;justify-content:space-between;gap:18px;align-items:center;padding:18px 0 24px;border-top:1px solid var(--line)}.filters{display:flex;flex-wrap:wrap;gap:7px}.filter{border:1px solid transparent;background:transparent;color:var(--muted);font-size:11px;padding:8px 14px;border-radius:30px}.filter[aria-pressed=true]{background:var(--gold-soft);border-color:var(--gold);color:var(--gold)}.legend{display:flex;align-items:center;gap:17px;font-size:10px;color:var(--muted)}.legend span{display:flex;align-items:center;gap:6px}.legend i{width:7px;height:7px;border-radius:2px}.legend .gold{background:var(--gold)}.legend .blue{background:var(--blue)}.gallery{display:grid;grid-template-columns:1fr 1fr;gap:22px}.card{min-width:0;background:var(--card);border:1px solid var(--line);border-radius:14px;overflow:hidden}.card.featured{grid-column:1/-1}.card[hidden]{display:none}.card{scroll-margin-top:88px}.card-head{padding:23px 25px 0;display:flex;justify-content:space-between;gap:12px;align-items:start}.title-row{display:flex;align-items:center;gap:11px}.number{display:flex;align-items:center;justify-content:center;width:27px;height:27px;border-radius:6px;border:1px solid var(--line);font-size:10px;color:var(--gold);font-variant-numeric:tabular-nums}.card h2{font-size:17px;font-weight:600;letter-spacing:-.03em;margin:0}.card-sub{font-size:11px;color:var(--muted);margin:8px 0 0;line-height:1.6}.pick{border:1px solid var(--line);border-radius:6px;background:transparent;font-size:10px;padding:7px 10px;white-space:nowrap}.pick[aria-pressed=true]{border-color:var(--gold);color:var(--gold);background:var(--gold-soft)}.plot{position:relative;margin:17px 20px 0;min-width:0;touch-action:pan-y}.plot svg{display:block;width:100%;height:auto;overflow:visible}.featured .plot{margin-top:8px}.plot .tip{position:absolute;z-index:3;pointer-events:none;min-width:140px;background:var(--card-hover);border:1px solid var(--line);border-radius:8px;box-shadow:0 9px 25px var(--shadow);padding:11px 13px;font-size:10px;transform:translate(-50%,-100%);visibility:hidden}.tip strong{display:block;font-weight:600;margin-bottom:7px}.tip div{display:flex;justify-content:space-between;gap:19px;margin-top:5px;color:var(--muted)}.tip b{color:var(--ink);font-weight:550;white-space:nowrap}.tip i{display:inline-block;width:6px;height:6px;border-radius:2px;margin-right:5px}.card-foot{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:17px 25px 20px;border-top:1px solid var(--line);margin-top:7px}.tag{font-size:9px;letter-spacing:.08em;text-transform:uppercase;color:var(--gold);white-space:nowrap}.use{font-size:10px;color:var(--muted);line-height:1.6}.card-feature-meta{display:flex;gap:30px;padding:24px 25px 0}.feature-value{font-size:28px;font-weight:500;letter-spacing:-.04em;font-variant-numeric:tabular-nums}.feature-label{font-size:10px;color:var(--muted);margin-bottom:6px}.feature-value span{font-size:11px;letter-spacing:0;margin-left:10px;color:var(--teal)}.depth{display:flex;align-items:center;gap:9px;font-size:10px;color:var(--muted)}.depth input{accent-color:var(--gold);width:85px}.svg-label{fill:var(--muted);font-size:10px;font-family:inherit}.svg-grid{stroke:var(--line);stroke-dasharray:2 6;stroke-width:1}.trace{stroke-dasharray:1800;stroke-dashoffset:1800;animation:draw 1.5s ease forwards}.trace.previous{animation-delay:.12s}.hover-line{stroke:var(--muted);stroke-dasharray:3 5;opacity:0}.hover-dot{opacity:0;stroke:var(--card);stroke-width:3}.spark-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px;padding:13px 9px 22px}.spark-item{border:1px solid var(--line);border-radius:9px;padding:14px 14px 7px}.spark-item p{font-size:10px;color:var(--muted);margin:0}.spark-item b{display:block;font-size:24px;font-weight:500;margin:6px 0 0;letter-spacing:-.04em}.spark-item svg{margin-top:5px}.shortlist{margin-top:30px;border:1px solid var(--line);border-radius:12px;background:var(--card);padding:23px 25px;display:flex;align-items:center;justify-content:space-between;gap:25px}.shortlist h2{font-size:16px;letter-spacing:-.02em;margin:0 0 8px}.shortlist p{font-size:12px;color:var(--muted);line-height:1.7;margin:0}.choice-list{font-size:12px;line-height:1.9;margin-top:12px;color:var(--gold)}.copy{background:var(--gold);color:#18202b;border:0;border-radius:6px;font-size:11px;font-weight:650;padding:11px 17px;white-space:nowrap}footer{margin-top:23px;display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:10px;line-height:1.7}.status{min-height:18px;color:var(--teal);font-size:11px;margin-top:7px}.intro a{color:var(--gold);text-decoration:none;border-bottom:1px solid var(--gold)}@keyframes draw{to{stroke-dashoffset:0}}@media(max-width:1000px){header{padding:16px 24px}.wrap{padding:0 24px 40px}.intro{grid-template-columns:1fr;gap:25px;padding-top:37px}.intro-aside{border-left:0;border-top:1px solid var(--line);padding:20px 0 0;grid-template-columns:80px 80px 1fr}.intro-aside p{grid-column:auto}.card h2{font-size:15px}.card-head{padding:20px 18px 0}.card-foot{padding:15px 18px}.plot{margin-left:12px;margin-right:12px}}@media(max-width:700px){header{padding:14px 16px}.brand{font-size:9px}.header-actions{gap:7px}.header-actions .quiet:not(.theme){padding:9px 8px;font-size:9px}.wrap{padding:0 16px 30px}.intro{padding:32px 0 26px}h1{font-size:44px}.intro-copy{font-size:12px}.intro-aside{grid-template-columns:60px 60px 1fr;gap:16px}.intro-aside strong{font-size:25px}.intro-aside p{font-size:9px}.gallery{grid-template-columns:1fr;gap:16px}.card.featured{grid-column:auto}.toolbar{gap:13px}.filter{font-size:10px;padding:8px 10px}.card-feature-meta{gap:20px;padding:20px 18px 0}.feature-value{font-size:24px}.shortlist{align-items:start;flex-direction:column;padding:20px}.card-foot{flex-wrap:wrap}footer{flex-direction:column}.pick{font-size:9px;padding:6px 8px}.card h2{font-size:15px}.plot .tip{font-size:9px;min-width:125px}.depth input{width:100px}.plot svg .svg-label{font-size:14px}.plot svg .svg-label[style]{font-size:inherit}.plot.is-cartesian svg .svg-label{font-size:9px}}
|
||
@media(prefers-reduced-motion:reduce){.trace{animation:none;stroke-dasharray:none;stroke-dashoffset:0}}
|
||
@media print{header,.toolbar,.pick,.shortlist,.depth{display:none}.wrap{padding:0}.intro{padding-top:0}.card{break-inside:avoid}.gallery{gap:12px}:root{--bg:white;--card:white;--ink:#263345;--line:#ddd;--muted:#667;--gold:#bd7921}.trace{animation:none;stroke-dasharray:none;stroke-dashoffset:0}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header><div class="identity"><div class="mark" aria-hidden="true"><i></i><i></i><i></i><i></i></div><div class="brand">Management <small>Chart direction / local preview</small></div></div><div class="header-actions"><button class="quiet" id="selection-jump">Shortlist <span id="count">0</span></button><button class="quiet theme" id="theme" aria-label="Switch to light theme"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.4 1.4m11.2 11.2L19 19M5 19l1.4-1.4M17.6 6.4 19 5"/></svg></button></div></header>
|
||
<div class="wrap">
|
||
<section class="intro"><div><p class="eyebrow">A collection for the management board</p><h1>Find your<br><em>chart language.</em></h1><p class="intro-copy">Sixteen visual directions, from sharp two-year lines to dimensional ribbons. Hover to explore, switch themes, and shortlist the ones you like. Tell me the chart numbers and we can use them in the dashboard.</p></div><div class="intro-aside"><div><strong>16</strong><span>chart concepts</span></div><div><strong>02</strong><span>themes</span></div><p>Illustrative figures in R millions.<br>These are style previews.<br>Start with <a href="#chart-01">01</a> or <a href="#chart-02">02</a> for Year Comparison.</p></div></section>
|
||
<div class="toolbar"><nav class="filters" aria-label="Chart categories"><button class="filter" data-filter="all" aria-pressed="true">All charts</button><button class="filter" data-filter="line" aria-pressed="false">Lines & areas</button><button class="filter" data-filter="2d" aria-pressed="false">2D</button><button class="filter" data-filter="3d" aria-pressed="false">3D</button><button class="filter" data-filter="other" aria-pressed="false">Other formats</button><button class="filter" data-filter="picked" aria-pressed="false">Shortlisted</button></nav><div class="legend"><span><i class="blue"></i>2024 / previous</span><span><i class="gold"></i>2025 / current</span></div></div>
|
||
<main id="gallery" class="gallery" aria-label="Chart style gallery"></main>
|
||
<section class="shortlist" id="shortlist"><div><h2>Your shortlist</h2><p>Choose any combination. The chart number and name make each option easy to request.</p><div class="choice-list" id="choices">No charts selected yet.</div><div class="status" id="status" role="status"></div></div><div><button class="copy" id="copy">Copy my choices</button><button class="quiet" id="clear" style="margin-left:8px">Clear</button></div></section>
|
||
<footer><span>Management chart gallery · Standalone HTML · Works offline</span><span>Illustrative data only · Hover charts or focus them and use ← / → to inspect values</span></footer>
|
||
</div>
|
||
<script>
|
||
'use strict';
|
||
const months=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||
const previous=[4.8,5.7,7.2,6.3,8.5,7.1,9.8,9.4,11.0,10.4,12.2,11.8];
|
||
const current=[5.4,6.8,6.1,8.2,7.9,10.4,12.1,11.6,13.5,12.9,15.8,14.7];
|
||
const costs=[5.0,6.1,6.4,7.3,7.1,9.5,10.9,10.8,12.0,11.8,14.0,13.6];
|
||
const C={gold:'var(--gold)',blue:'var(--blue)',teal:'var(--teal)',red:'var(--red)',muted:'var(--muted)'};
|
||
const specs=[
|
||
{id:'01',name:'Smooth comparison',kind:'smooth',groups:['line','2d'],tag:'Dual spline',use:'Best for monthly year comparison',sub:'Two clear curves, subtle fills and an exact-value crosshair.',featured:true},
|
||
{id:'02',name:'Gradient mountain',kind:'area',groups:['line','2d'],tag:'Gradient area',use:'Turnover overview / income comparison',sub:'A warm current-year area with a quiet previous-year baseline.'},
|
||
{id:'03',name:'Luminous focus',kind:'glow',groups:['line','2d'],tag:'Focus line',use:'One metric with a premium visual finish',sub:'A luminous trace, floating points and a restrained glow.'},
|
||
{id:'04',name:'Point-by-point',kind:'straight',groups:['line','2d'],tag:'Straight line + markers',use:'Exact month-to-month comparison',sub:'Crisp segments and distinct points make each month visible.'},
|
||
{id:'05',name:'Stepped plan',kind:'step',groups:['line','2d'],tag:'Step line',use:'Budget revisions / committed targets',sub:'Each monthly value holds until the next period begins.'},
|
||
{id:'06',name:'Layered horizons',kind:'layered',groups:['line','2d'],tag:'Overlapping areas',use:'Two related income trends',sub:'Two translucent landscapes show where the years separate.'},
|
||
{id:'07',name:'Income + cost',kind:'combo',groups:['line','2d'],tag:'Column + line',use:'Turnover against monthly costs',sub:'Income columns sit beneath a clear cost line.'},
|
||
{id:'08',name:'Dimensional ribbon',kind:'ribbon',groups:['line','3d'],tag:'3D perspective ribbon',use:'Presentation / visual year comparison',sub:'Two raised ribbons on a shared perspective floor.'},
|
||
{id:'09',name:'Raised columns',kind:'3dbars',groups:['3d'],tag:'3D perspective columns',use:'Presentation / dimensional overview',sub:'Two rows of extruded columns. Adjust the depth below.'},
|
||
{id:'10',name:'Paired columns',kind:'bars',groups:['2d'],tag:'Grouped bar',use:'Side-by-side monthly year comparison',sub:'The existing comparison format, with a themed hover highlight.'},
|
||
{id:'11',name:'Result bridge',kind:'waterfall',groups:['2d','other'],tag:'Waterfall',use:'Opening balance → additions → closing',sub:'Follow the movement from opening to closing retention.'},
|
||
{id:'12',name:'Portfolio ring',kind:'donut',groups:['2d','other'],tag:'Donut',use:'Project / client share of turnover',sub:'A quiet centre total with an interactive segment legend.'},
|
||
{id:'13',name:'Planning intensity',kind:'heatmap',groups:['2d','other'],tag:'Heatmap',use:'Project income schedule / concentration',sub:'A compact grid reveals activity across the whole portfolio.'},
|
||
{id:'14',name:'Executive sparklines',kind:'sparks',groups:['line','2d','other'],tag:'KPI + sparkline',use:'Headline statistics with mini trends',sub:'Small lines add context to turnover, costs, result and retention.'},
|
||
{id:'15',name:'Seasonal radar',kind:'radar',groups:['2d','other'],tag:'Radar',use:'A visual view of seasonality',sub:'Six two-month periods give each year its own silhouette.'},
|
||
{id:'16',name:'Project ranking',kind:'ranking',groups:['2d','other'],tag:'Horizontal bar',use:'Largest project schedules / client ranking',sub:'A clear ranked comparison with values beside every bar.'},
|
||
];
|
||
const gallery=document.getElementById('gallery');
|
||
let picked=new Set();try{picked=new Set(JSON.parse(localStorage.getItem('mega-chart-gallery-picks')||'[]'));}catch{}
|
||
let theme='dark';try{theme=localStorage.getItem('mega-chart-gallery-theme')||'dark';}catch{}
|
||
let activeFilter='all';
|
||
const sum=a=>a.reduce((s,v)=>s+v,0);
|
||
const million=v=>'R'+v.toFixed(2)+'m';
|
||
const money=v=>new Intl.NumberFormat('en-ZA',{style:'currency',currency:'ZAR',maximumFractionDigits:2,minimumFractionDigits:2}).format(v*1000000);
|
||
const line=(x1,y1,x2,y2,attrs='')=>`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" ${attrs}/>`;
|
||
const text=(x,y,t,anchor='start',attrs='')=>`<text x="${x}" y="${y}" text-anchor="${anchor}" class="svg-label" ${attrs}>${t}</text>`;
|
||
const circle=(x,y,r,fill,attrs='')=>`<circle cx="${x}" cy="${y}" r="${r}" fill="${fill}" ${attrs}/>`;
|
||
const poly=(pts,fill,attrs='')=>`<polygon points="${pts.map(p=>p.join(',')).join(' ')}" fill="${fill}" ${attrs}/>`;
|
||
const svg=(spec,body,w=600,h=280)=>`<svg viewBox="0 0 ${w} ${h}" role="img" aria-label="${spec.name}: ${spec.tag} using illustrative data">${body}</svg>`;
|
||
function smooth(points){if(!points.length)return'';let path=`M${points[0][0]},${points[0][1]}`;for(let i=1;i<points.length;i++){const a=points[i-1],b=points[i],p=points[i-2]||a,n=points[i+1]||b;const dx=b[0]-a[0];const d=b[1]-a[1];const left=(b[1]-p[1])/(b[0]-p[0]||1),right=(n[1]-a[1])/(n[0]-a[0]||1);const limit=Math.abs(d)*.45;const l=Math.max(-limit,Math.min(limit,left*dx/3)),r=Math.max(-limit,Math.min(limit,right*dx/3));path+=` C${a[0]+dx/3},${a[1]+l} ${b[0]-dx/3},${b[1]-r} ${b[0]},${b[1]}`;}return path;}
|
||
const straight=points=>'M'+points.map(p=>p.join(',')).join(' L');
|
||
function step(points){return points.reduce((s,p,i)=>i?s+` H${p[0]} V${p[1]}`:`M${p[0]},${p[1]}`,'');}
|
||
function frame(w=600,h=280,max=20){const l=43,r=w-14,t=20,b=h-36;const x=i=>l+(r-l)*i/11;const y=v=>b-(b-t)*v/max;let body='';for(let v=0;v<=max;v+=max/4){body+=line(l,y(v),r,y(v),'class="svg-grid"')+text(l-9,y(v)+3,`R${v}m`,'end');}months.forEach((m,i)=>{body+=text(x(i),h-13,m,'middle');});return{l,r,t,b,x,y,body,w,h};}
|
||
function defs(id){return`<defs><linearGradient id="gold-${id}" x1="0" y1="0" x2="0" y2="1"><stop stop-color="${C.gold}" stop-opacity=".33"/><stop offset="1" stop-color="${C.gold}" stop-opacity="0"/></linearGradient><linearGradient id="blue-${id}" x1="0" y1="0" x2="0" y2="1"><stop stop-color="${C.blue}" stop-opacity=".17"/><stop offset="1" stop-color="${C.blue}" stop-opacity="0"/></linearGradient><filter id="glow-${id}" x="-20%" y="-40%" width="140%" height="180%"><feGaussianBlur stdDeviation="4"/></filter></defs>`;}
|
||
function cartesian(spec,width=spec.featured?1200:600){const f=frame(width,width<500?245:spec.featured?300:280);const p=current.map((v,i)=>[f.x(i),f.y(v)]),q=previous.map((v,i)=>[f.x(i),f.y(v)]);const fn=spec.kind==='straight'?straight:spec.kind==='step'?step:smooth;const a=fn(p),b=fn(q);let body=defs(spec.id)+f.body;const fill=(d,color)=>`<path d="${d} L${f.r},${f.b} L${f.l},${f.b} Z" fill="url(#${color}-${spec.id})"/>`;
|
||
if(['smooth','area','layered','glow'].includes(spec.kind))body+=fill(a,'gold');if(spec.kind==='layered')body+=fill(b,'blue');
|
||
if(spec.kind==='combo'){current.forEach((v,i)=>{body+=`<rect x="${f.x(i)-10}" y="${f.y(v)}" width="20" height="${f.b-f.y(v)}" fill="${C.gold}" opacity=".82" rx="3"/>`;});body+=`<path d="${smooth(costs.map((v,i)=>[f.x(i),f.y(v)]))}" fill="none" stroke="${C.teal}" stroke-width="2.5" class="trace"/>`;}
|
||
else if(spec.kind==='bars'){const width=Math.min(14,(f.r-f.l)/12*.3);previous.forEach((v,i)=>{body+=`<rect x="${f.x(i)-width-2}" y="${f.y(v)}" width="${width}" height="${f.b-f.y(v)}" fill="${C.blue}" rx="3"/><rect x="${f.x(i)+2}" y="${f.y(current[i])}" width="${width}" height="${f.b-f.y(current[i])}" fill="${C.gold}" rx="3"/>`;});}
|
||
else{if(spec.kind!=='glow')body+=`<path d="${b}" fill="none" stroke="${C.blue}" stroke-width="2" opacity=".85" ${spec.kind==='area'?'stroke-dasharray="4 5"':''} class="trace previous"/>`;if(spec.kind==='glow')body+=`<path d="${a}" fill="none" stroke="${C.gold}" stroke-width="8" opacity=".35" filter="url(#glow-${spec.id})"/>`;body+=`<path d="${a}" fill="none" stroke="${C.gold}" stroke-width="${spec.kind==='glow'?3:2.6}" class="trace"/>`;if(['straight','glow'].includes(spec.kind)){p.forEach(point=>body+=circle(...point,3.4,C.gold,'stroke="var(--card)" stroke-width="2"'));if(spec.kind==='straight')q.forEach(point=>body+=circle(...point,2.9,C.blue));}}
|
||
body+=`<line class="hover-line" x1="${f.l}" x2="${f.l}" y1="${f.t}" y2="${f.b}"/><circle class="hover-dot primary" r="5" fill="${C.gold}"/><circle class="hover-dot secondary" r="4" fill="${spec.kind==='combo'?C.teal:C.blue}"/><rect class="hit" x="${f.l}" y="${f.t}" width="${f.r-f.l}" height="${f.b-f.t}" fill="transparent"/>`;
|
||
return{html:svg(spec,body,f.w,f.h),frame:f};}
|
||
function perspective(spec,depth=1){const w=600,h=295,l=55,b=236,dx=37*depth,dy=20*depth,sx=42,sy=9.4;const pt=(i,v,z)=>[l+i*sx+z*dx,b-v*sy-z*dy];let body='';const floor=[pt(0,0,0),pt(11.5,0,0),pt(11.5,0,1.7),pt(0,0,1.7)];body+=poly(floor,'var(--card-hover)','stroke="var(--line)"');[0,5,10,15,20].forEach(v=>{const a=pt(0,v,0),c=pt(11.5,v,0);body+=line(...a,...c,'class="svg-grid"')+text(a[0]-10,a[1]+3,`R${v}m`,'end');});months.forEach((m,i)=>{const a=pt(i,0,0),c=pt(i,0,1.7);body+=line(...a,...c,'stroke="var(--line)" stroke-width=".7"')+text(a[0],a[1]+22,m,'middle');});
|
||
function ribbon(values,z,color){const front=values.map((v,i)=>pt(i,v,z));const back=values.map((v,i)=>pt(i,v,z+0.3));const bottom=values.map((v,i)=>pt(i,0,z)).reverse();body+=poly([...front,...bottom],color,'opacity=".16"');body+=poly([...front,...back.reverse()],color,'opacity=".8"');body+=`<path d="${smooth(front)}" fill="none" stroke="${color}" stroke-width="2.5"/>`;front.forEach(p=>body+=circle(...p,2.4,color));}
|
||
function bars(values,z,color){values.forEach((v,i)=>{const [x,y]=pt(i,v,z),[bx,by]=pt(i,0,z),width=15;const sx=dx*.28,sy=-dy*.28;body+=poly([[x,y],[x+width,y],[bx+width,by],[bx,by]],color,'opacity=".87"');body+=poly([[x+width,y],[x+width+sx,y+sy],[bx+width+sx,by+sy],[bx+width,by]],color,'opacity=".42"');body+=poly([[x,y],[x+width,y],[x+width+sx,y+sy],[x+sx,y+sy]],color);body+=`<rect x="${x}" y="${y}" width="${width+sx}" height="${by-y}" fill="transparent" data-tip="${months[i]} · ${z>0?'2024':'2025'}" data-value="${v}"/>`;});}
|
||
if(spec.kind==='ribbon'){ribbon(previous,1.15,C.blue);ribbon(current,.1,C.gold);current.forEach((v,i)=>{const p=pt(i,v,.1);body+=circle(...p,10,'transparent',`data-tip="${months[i]} · 2025" data-value="${v}"`);});}else{bars(previous,1.15,C.blue);bars(current,0,C.gold);}
|
||
return svg(spec,body,w,h);}
|
||
function waterfall(spec){const values=[7.7,4.2,-1.8,3.4,-1.7,2.6,14.4],labels=['Opening','Q1 added','Q1 paid','Q2 added','Q2 paid','Q3 added','Closing'];const l=43,r=582,b=240,t=22,max=18,y=v=>b-(b-t)*v/max;let body='';[0,6,12,18].forEach(v=>body+=line(l,y(v),r,y(v),'class="svg-grid"')+text(l-9,y(v)+3,`R${v}m`,'end'));let total=0;values.forEach((v,i)=>{const x=65+i*77,base=i===0||i===6?0:total;const top=i===6?v:base+v;const low=Math.min(base,top),high=Math.max(base,top);body+=`<rect x="${x}" y="${y(high)}" width="44" height="${y(low)-y(high)}" rx="3" fill="${i===0||i===6?C.gold:v<0?C.red:C.teal}" data-tip="${labels[i]}" data-value="${v}"/>`+text(x+22,y(high)-9,`${v<0?'−':''}R${Math.abs(v).toFixed(1)}m`,'middle')+text(x+22,267,labels[i],'middle');total=top;if(i<5)body+=line(x+44,y(total),x+77,y(total),'stroke="var(--muted)" stroke-dasharray="3 4" opacity=".4"');});return svg(spec,body);}
|
||
function donut(spec){const vals=[31,24,19,15,11],names=['Project North','Project Central','Project East','Project West','Other projects'],colors=[C.gold,C.teal,C.blue,'#c394ba','#657284'];const cx=173,cy=139,r=88,circ=2*Math.PI*r;let body='';let angle=0;vals.forEach((v,i)=>{body+=`<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${colors[i]}" stroke-width="23" stroke-dasharray="${circ*v/100-4} ${circ}" stroke-dashoffset="${-circ*angle/100}" transform="rotate(-90 ${cx} ${cy})" data-tip="${names[i]} · ${v}% of turnover" data-value="${v*1.334}"/>`;angle+=v;body+=`<rect x="335" y="${51+i*40}" width="8" height="8" rx="2" fill="${colors[i]}"/>`+text(353,59+i*40,names[i])+text(572,59+i*40,v+'%','end','style="fill:var(--ink)"');});body+=text(cx,133,'R133.40m','middle','style="font-size:25px;fill:var(--ink);font-weight:500;letter-spacing:-1px"')+text(cx,157,'PLANNED TURNOVER','middle','style="font-size:8px;letter-spacing:1px"');return svg(spec,body);}
|
||
function heatmap(spec){const names=['North','Central','East','West','South'],base=[.6,.9,.4,.7,.35];let body='';months.forEach((m,i)=>body+=text(118+i*38,31,m,'middle'));names.forEach((name,row)=>{body+=text(79,66+row*40,name,'end');months.forEach((m,i)=>{const value=Math.max(0,Math.sin(i*.65+row)*.7+1.1)*base[row]*2;body+=`<rect x="${101+i*38}" y="${46+row*40}" width="32" height="31" rx="4" fill="${C.gold}" opacity="${.07+value/4.0*.8}" data-tip="Project ${name} · ${m}" data-value="${value}"/>`+text(117+i*38,66+row*40,value.toFixed(1),'middle','style="font-size:9px;fill:var(--ink);pointer-events:none"');});});body+=text(99,265,'Low activity','start')+text(550,265,'High activity','end');for(let i=0;i<6;i++)body+=`<rect x="${304+i*19}" y="254" width="15" height="10" rx="2" fill="${C.gold}" opacity="${.1+i*.16}"/>`;return svg(spec,body);}
|
||
function sparks(spec){const sets=[{label:'Planned turnover',value:sum(current),data:current,color:C.gold},{label:'Planned costs',value:sum(costs),data:costs,color:C.blue},{label:'Operating result',value:sum(current)-sum(costs),data:current.map((v,i)=>v-costs[i]),color:C.teal},{label:'Closing retention',value:14.4,data:[7.7,8.1,8.6,8.0,8.7,9.1,9.8,10.6,11.2,12.3,13.6,14.4],color:'#b49ad1'}];return`<div class="spark-grid">${sets.map(s=>{const max=Math.max(...s.data)*1.15,min=Math.min(0,...s.data),pts=s.data.map((v,i)=>[i*20,55-(v-min)/(max-min)*50]);return`<div class="spark-item"><p>${s.label}</p><b>${million(s.value)}</b><svg viewBox="0 0 220 67" role="img" aria-label="${s.label} mini trend"><path d="${smooth(pts)}" fill="none" stroke="${s.color}" stroke-width="2" class="trace"/>${circle(...pts[pts.length-1],2.8,s.color)}</svg></div>`;}).join('')}</div>`;}
|
||
function radar(spec){const cx=275,cy=143,r=105,names=['Jan–Feb','Mar–Apr','May–Jun','Jul–Aug','Sep–Oct','Nov–Dec'];const pos=(i,v)=>[cx+Math.cos(i*Math.PI/3-Math.PI/2)*r*v,cy+Math.sin(i*Math.PI/3-Math.PI/2)*r*v];let body='';[.25,.5,.75,1].forEach(k=>body+=poly(names.map((_,i)=>pos(i,k)),'none','stroke="var(--line)"'));names.forEach((n,i)=>{body+=line(cx,cy,...pos(i,1),'stroke="var(--line)"')+text(...pos(i,1.24),n,'middle');});[previous,current].forEach((series,year)=>{const points=names.map((_,i)=>pos(i,(series[i*2]+series[i*2+1])/34));const color=year?C.gold:C.blue;body+=poly(points,color,'fill-opacity=".12" stroke="'+color+'" stroke-width="2"');points.forEach((p,i)=>body+=circle(...p,3.7,color,`data-tip="${names[i]} · ${year?'2025':'2024'}" data-value="${series[i*2]+series[i*2+1]}"`));});return svg(spec,body);}
|
||
function ranking(spec){const names=['Project North','Project Central','Project East','Project West','Project South'],vals=[31.2,24.9,19.7,15.3,11.2];let body='';vals.forEach((v,i)=>{const y=28+i*46;body+=text(13,y+13,String(i+1).padStart(2,'0'),'start','style="fill:var(--gold)"')+text(42,y+13,names[i])+`<rect x="178" y="${y}" width="310" height="22" rx="4" fill="var(--card-hover)"/><rect x="178" y="${y}" width="${v/35*310}" height="22" rx="4" fill="${C.gold}" opacity="${1-i*.12}" data-tip="${names[i]}" data-value="${v}"/>`+text(577,y+15,million(v),'end','style="fill:var(--ink)"');});return svg(spec,body);}
|
||
const renderers={waterfall,donut,heatmap,sparks,radar,ranking};
|
||
const chartData=new Map();
|
||
for(const spec of specs){const article=document.createElement('article');article.className='card'+(spec.featured?' featured':'');article.id='chart-'+spec.id;article.dataset.groups=spec.groups.join(' ');article.innerHTML=`<div class="card-head"><div><div class="title-row"><span class="number">${spec.id}</span><h2>${spec.name}</h2></div><p class="card-sub">${spec.sub}</p></div><button class="pick" aria-label="Shortlist ${spec.id} ${spec.name}" aria-pressed="${picked.has(spec.id)}" data-pick="${spec.id}">${picked.has(spec.id)?'Selected ✓':'Shortlist +'}</button></div>${spec.featured?`<div class="card-feature-meta"><div><div class="feature-label">Illustrative current-year turnover</div><div class="feature-value">${million(sum(current))}<span>+${((sum(current)/sum(previous)-1)*100).toFixed(1)}%</span></div></div><div><div class="feature-label">Previous year</div><div class="feature-value" style="color:var(--muted)">${million(sum(previous))}</div></div></div>`:''}<div class="plot" tabindex="${['sparks'].includes(spec.kind)?'-1':'0'}" aria-label="${spec.name}; hover for values or use the left and right arrow keys"></div><div class="card-foot"><span class="tag">${spec.tag}</span>${spec.groups.includes('3d')?`<label class="depth">Depth <input type="range" min="0.25" max="1.7" step="0.05" value="1" aria-label="${spec.name} perspective depth"></label>`:`<span class="use">${spec.use}</span>`}</div>`;gallery.appendChild(article);const plot=article.querySelector('.plot');let result;if(spec.groups.includes('3d'))plot.innerHTML=perspective(spec);else if(renderers[spec.kind])plot.innerHTML=renderers[spec.kind](spec);else{plot.classList.add("is-cartesian");result=cartesian(spec,Math.max(300,Math.round(plot.clientWidth)));plot.innerHTML=result.html;}const tip=document.createElement('div');tip.className='tip';tip.setAttribute('aria-live','polite');plot.appendChild(tip);chartData.set(spec.id,{spec,article,plot,tip,frame:result?.frame});if(spec.groups.includes('3d'))article.querySelector('input').addEventListener('input',e=>{plot.querySelector('svg').outerHTML=perspective(spec,Number(e.target.value));});}
|
||
function showTip(entry,title,rows,px,py){entry.tip.innerHTML=`<strong>${title}</strong>${rows.map(r=>`<div><span><i style="background:${r.color||C.gold}"></i>${r.name}</span><b>${r.value}</b></div>`).join('')}`;const width=entry.plot.clientWidth;const tipWidth=entry.tip.offsetWidth;const min=tipWidth/2+4;entry.tip.style.left=Math.max(min,Math.min(width-min,px))+'px';entry.tip.style.top=Math.max(entry.tip.offsetHeight+5,py-9)+'px';entry.tip.style.visibility='visible';}
|
||
function inspectItem(entry,index){const targets=[...entry.plot.querySelectorAll('[data-tip]')];if(!targets.length)return;const target=targets[Math.max(0,Math.min(targets.length-1,index))];entry.itemIndex=targets.indexOf(target);const rect=entry.plot.getBoundingClientRect(),point=target.getBoundingClientRect();showTip(entry,target.dataset.tip,[{name:'Amount',value:money(Number(target.dataset.value))}],point.x-rect.x+point.width/2,point.y-rect.y);}
|
||
function hide(entry){entry.tip.style.visibility='hidden';entry.plot.querySelectorAll('.hover-line,.hover-dot').forEach(e=>e.style.opacity=0);}
|
||
function inspectMonth(entry,index){const{frame:f,spec,plot}=entry;const rect=plot.querySelector('svg').getBoundingClientRect(),sx=rect.width/f.w,sy=rect.height/f.h,x=f.x(index),v=current[index],second=spec.kind==='combo'?costs[index]:previous[index];const line=plot.querySelector('.hover-line');line.setAttribute('x1',x);line.setAttribute('x2',x);line.style.opacity=.65;const primary=plot.querySelector('.primary');primary.setAttribute('cx',x);primary.setAttribute('cy',f.y(v));primary.style.opacity=1;const secondary=plot.querySelector('.secondary');secondary.setAttribute('cx',x);secondary.setAttribute('cy',f.y(second));secondary.style.opacity=spec.kind==='glow'?0:1;const rows=[{name:spec.kind==='combo'?'Income':'2025',value:money(v),color:C.gold}];if(spec.kind!=='glow')rows.push({name:spec.kind==='combo'?'Cost':'2024',value:money(second),color:spec.kind==='combo'?C.teal:C.blue});showTip(entry,months[index],rows,x*sx,Math.min(f.y(v),f.y(second))*sy);entry.index=index;}
|
||
chartData.forEach(entry=>{entry.plot.addEventListener('pointermove',e=>{if(entry.frame){const rect=entry.plot.querySelector('svg').getBoundingClientRect();const sx=rect.width/entry.frame.w;const i=Math.round(((e.clientX-rect.left)/sx-entry.frame.l)/(entry.frame.r-entry.frame.l)*11);inspectMonth(entry,Math.max(0,Math.min(11,i)));}else{const target=e.target.closest('[data-tip]');if(!target){hide(entry);return;}const rect=entry.plot.getBoundingClientRect();showTip(entry,target.dataset.tip,[{name:'Amount',value:money(Number(target.dataset.value))}],e.clientX-rect.left,e.clientY-rect.top);}});entry.plot.addEventListener('pointerleave',()=>hide(entry));entry.plot.addEventListener('blur',()=>hide(entry));entry.plot.addEventListener('focus',()=>{if(entry.frame)inspectMonth(entry,0);else inspectItem(entry,0);});entry.plot.addEventListener('keydown',e=>{if(!['ArrowLeft','ArrowRight','Escape'].includes(e.key))return;e.preventDefault();if(e.key==='Escape'){hide(entry);return;}if(entry.frame)inspectMonth(entry,Math.max(0,Math.min(11,(entry.index||0)+(e.key==='ArrowRight'?1:-1))));else inspectItem(entry,(entry.itemIndex||0)+(e.key==='ArrowRight'?1:-1));});});
|
||
const sizeObserver=new ResizeObserver(entries=>{for(const observed of entries){const entry=[...chartData.values()].find(e=>e.plot===observed.target);if(!entry?.frame||entry.article.hidden)continue;const width=Math.max(300,Math.round(entry.plot.clientWidth));if(width===entry.frame.w)continue;const result=cartesian(entry.spec,width);entry.plot.querySelector('svg').outerHTML=result.html;entry.frame=result.frame;hide(entry);}});chartData.forEach(entry=>{if(entry.frame)sizeObserver.observe(entry.plot);});
|
||
document.querySelectorAll('.intro a').forEach(link=>link.addEventListener('click',e=>{e.preventDefault();activeFilter='all';filterCards();document.querySelector(link.getAttribute('href')).scrollIntoView({behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});}));
|
||
function filterCards(){specs.forEach(spec=>{chartData.get(spec.id).article.hidden=activeFilter==='picked'?!picked.has(spec.id):activeFilter!=='all'&&!spec.groups.includes(activeFilter);});document.querySelectorAll('[data-filter]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.filter===activeFilter)));}
|
||
function updatePicks(){document.getElementById('count').textContent=picked.size;document.getElementById('choices').textContent=specs.filter(s=>picked.has(s.id)).map(s=>`${s.id} · ${s.name}`).join(' / ')||'No charts selected yet.';document.querySelectorAll('[data-pick]').forEach(b=>{const selected=picked.has(b.dataset.pick);b.setAttribute('aria-pressed',String(selected));b.textContent=selected?'Selected ✓':'Shortlist +';});try{localStorage.setItem('mega-chart-gallery-picks',JSON.stringify([...picked]));}catch{}filterCards();}
|
||
document.querySelectorAll('[data-pick]').forEach(b=>b.addEventListener('click',()=>{picked.has(b.dataset.pick)?picked.delete(b.dataset.pick):picked.add(b.dataset.pick);document.getElementById('status').textContent='';updatePicks();}));
|
||
document.querySelectorAll('[data-filter]').forEach(b=>b.addEventListener('click',()=>{activeFilter=b.dataset.filter;filterCards();}));
|
||
function applyTheme(){document.documentElement.dataset.theme=theme;document.getElementById('theme').setAttribute('aria-label',`Switch to ${theme==='dark'?'light':'dark'} theme`);try{localStorage.setItem('mega-chart-gallery-theme',theme);}catch{}}
|
||
document.getElementById('theme').addEventListener('click',()=>{theme=theme==='dark'?'light':'dark';applyTheme();});
|
||
document.getElementById('selection-jump').addEventListener('click',()=>document.getElementById('shortlist').scrollIntoView({behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'}));
|
||
document.getElementById('clear').addEventListener('click',()=>{picked.clear();document.getElementById('status').textContent='';updatePicks();});
|
||
document.getElementById('copy').addEventListener('click',async()=>{const status=document.getElementById('status');const choices=specs.filter(s=>picked.has(s.id)).map(s=>`${s.id} — ${s.name}`);if(!choices.length){status.textContent='Select at least one chart first.';return;}const content='My preferred Management charts:\n'+choices.join('\n');try{await navigator.clipboard.writeText(content);status.textContent='Copied. Paste these choices into the chat.';}catch{status.textContent='Copy these choices: '+choices.join('; ');}});
|
||
applyTheme();updatePicks();
|
||
</script>
|
||
</body>
|
||
</html>
|