From 4caa89088a1d4e9c277990f977db96f7a4255407 Mon Sep 17 00:00:00 2001 From: holoros Date: Fri, 12 Jun 2026 04:54:59 -0400 Subject: [PATCH] =?UTF-8?q?fix(chart):=20legibility=20pass=20=E2=80=94=20l?= =?UTF-8?q?arger=20text,=20FIA=20pill,=20stub=20dots,=20hover/CSV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the growth chart reading as heavy/small/cluttered: - axis tick + unit text larger (10->12) and higher contrast (#8aa0b0->#b6c6d0) - FIA observed label now sits in a dark pill so it stays readable over the data lines instead of colliding with them - end-gutter labels enlarged (7.5->9.5) with more stacking room - short anchor stubs (gcbm_moja_v6, 2022-2026) render as dots only, so no stray floating dashed segment - roomier margins + taller plot; folds in hover-to-highlight dimming and the CSV export. Build verified. Supersedes #74 (stub fix) and #77 (hover-dim + CSV). --- src/GrowthChart.jsx | 70 +++++++++++++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/src/GrowthChart.jsx b/src/GrowthChart.jsx index 19c7073..e9f451b 100644 --- a/src/GrowthChart.jsx +++ b/src/GrowthChart.jsx @@ -7,9 +7,10 @@ export default function GrowthChart({ node, fiaRef, fiaYear, unit, classCol, showBands, showInvBand, hiddenEngines, yMode, overlayNode, overlayLabel, isolatedEngine, onIsolate, xMax }){ - const W=560,H=320,L=48,R=70,T=14,B=30; + const W=560,H=344,L=56,R=86,T=22,B=38; const svgRef = useRef(null); const [hoverX, setHoverX] = useState(null); + const [hoverModel, setHoverModel] = useState(null); // Optional user x-axis horizon clamp (projections run to 2125). const clampX = s => (xMax && s.pts) ? {...s, pts: s.pts.filter(p=>p[0]<=xMax)} : s; @@ -94,7 +95,7 @@ export default function GrowthChart({ node, fiaRef, fiaYear, unit, classCol, const yy=Y(v); grid.push( - + {v>=1000?(v/1000).toFixed(1)+"k":v.toFixed(ydec)} ); @@ -105,7 +106,7 @@ export default function GrowthChart({ node, fiaRef, fiaYear, unit, classCol, const xmag = Math.pow(10, Math.floor(Math.log10(xraw))), xnorm = xraw/xmag; const xstep = Math.max(1, (xnorm<1.5?1:xnorm<3?2:xnorm<7?5:10)*xmag); const xticks=[]; for(let t=Math.ceil(x0/xstep)*xstep; t<=x1+1e-6; t+=xstep) - xticks.push({Math.round(t)}); + xticks.push({Math.round(t)}); const bands = showBands ? visible.filter(s=> s.pts.some(p=>p.length>=4)).map((s,i)=>{ const col = classCol[s.cls] || "#bbb"; const bp = s.pts.filter(p=>p.length>=4); @@ -133,16 +134,25 @@ export default function GrowthChart({ node, fiaRef, fiaYear, unit, classCol, const col = shadeFor(s); const d = s.pts.map((p,k)=> (k? "L":"M") + X(p[0]).toFixed(1) + " " + Y(p[1]).toFixed(1)).join(" "); const tag = dashed ? `${s.label} · ${overlayLabel||"compare"}` : `${s.label}`; + // A few engines are short anchor stubs (e.g. gcbm_moja_v6 spans only + // 2022-2026) rather than full trajectories. Drawn as a line on a full- + // horizon chart they read as a stray floating segment, so render them as a + // few dots instead — clearly measured anchor points, no phantom line. + const spanFrac = s.pts.length>1 ? (s.pts[s.pts.length-1][0]-s.pts[0][0])/((x1-x0)||1) : 0; + const stub = s.pts.length<=3 || spanFrac < 0.25; + const dim = hoverModel && hoverModel!==s.model; // hover-to-highlight return - setHoverModel(s.model)} onMouseLeave={()=> setHoverModel(null)} onClick={()=> !dashed && onIsolate && onIsolate(s.model)}> - {`${tag} (${s.cls}) — click to isolate`} + {`${tag} (${s.cls}) — ${stub?"short anchor series · ":""}hover to highlight, click to isolate`} - + {stub + ? s.pts.map((p,k)=>()) + : } ; }; // Collision-avoided trailing labels: stack each line's end label in the right @@ -151,7 +161,7 @@ export default function GrowthChart({ node, fiaRef, fiaYear, unit, classCol, .map(({s,dashed})=>({ model:s.model, col:shadeFor(s), dash:dashFor(s), dashed, y0:Math.max(T+5, Math.min(H-B-3, Y(s.pts[s.pts.length-1][1]))) })) .sort((a,b)=>a.y0-b.y0); - { const top=T+4, bot=H-B-3, GAP=9.5; + { const top=T+4, bot=H-B-3, GAP=11.5; // pass 1: top-down, never overlapping let prev = top - GAP; for(const it of labelItems){ it.ly = Math.max(it.y0, prev + GAP); prev = it.ly; } @@ -166,14 +176,14 @@ export default function GrowthChart({ node, fiaRef, fiaYear, unit, classCol, // and hover/click identifies any line). When isolating, always label. const DENSE = labelItems.length > 12 && !isolatedEngine; const endLabels = DENSE - ? [ + ? [ {labelItems.length} engines, - + hover / click to ID] : labelItems.map((it,k)=>( - - + + {it.model.replace(/_/g," ").slice(0,15)}{it.dashed?" ·"+overlayLabel:""} )); @@ -232,16 +242,32 @@ export default function GrowthChart({ node, fiaRef, fiaYear, unit, classCol, img.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(data))); }; + // Download the visible engine series as CSV (year column + one per engine). + const downloadCsv = () => { + const ser = [...drawSet, ...drawOverlay]; + if(!ser.length) return; + const yrs = [...new Set(ser.flatMap(s=>s.pts.map(p=>p[0])))].sort((a,b)=>a-b); + const seen={}; const head=["year", ...ser.map(s=>{ let n=s.model; if(seen[n]!=null){n=n+"_"+(++seen[s.model]);} else seen[s.model]=0; return n; })]; + const rows=yrs.map(y=>[y, ...ser.map(s=>{ const v=valueAt(s,y); return v==null?"":(+v).toFixed(4); })]); + const csv=[head.join(","), ...rows.map(r=>r.join(","))].join("\n"); + const a=document.createElement("a"); a.href=URL.createObjectURL(new Blob([csv],{type:"text/csv"})); + a.download=`perseus_series_${Date.now()}.csv`; a.click(); URL.revokeObjectURL(a.href); + }; + return (
setHoverX(null)}> {grid}{xticks}{invBand}{bands} - {fiaRef!=null && fiaRef >= y0 && fiaRef <= y1 && <> - - FIA observed {fiaRef} Tg{fiaYear?` (${fiaYear})`:""} - } + {fiaRef!=null && fiaRef >= y0 && fiaRef <= y1 && (()=>{ + const t=`FIA observed ${fiaRef} Tg${fiaYear?` (${fiaYear})`:""}`; + const w=t.length*5.7+12; + return + + + {t} + ; })()} {drawSet.map((s,i)=> drawLine(s, i, false))} {drawOverlay.map((s,i)=> drawLine(s, i, true))} {endLabels} @@ -250,7 +276,7 @@ export default function GrowthChart({ node, fiaRef, fiaYear, unit, classCol, stroke="#ffffff" strokeOpacity="0.3" strokeWidth="1" strokeDasharray="3 3" style={{pointerEvents:"none"}}/> )} - {unit||""} + {unit||""} {hoverX != null && hoverYear != null && (
↓ PNG +
);