"""Render the checked local snapshot only. Requires matplotlib and Pillow. No network."""
from pathlib import Path
import csv, hashlib, json, os
os.environ.setdefault("MPLCONFIGDIR", "/tmp/meplar-mplconfig")
os.environ.setdefault("XDG_CACHE_HOME", "/tmp/meplar-cache")
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
from PIL import Image
import argparse
p=argparse.ArgumentParser();p.add_argument('--out',type=Path,required=True);args=p.parse_args()
D=args.out.resolve();D.mkdir(parents=True,exist_ok=True)
rows=list(csv.DictReader((D/'chart-data.csv').open()))
assert len(rows)==28 and len({r['geo'] for r in rows})==28
assert rows[0]['geo']=='EU27_2020'
for r in rows:
    for k in ['above_threshold_pct','below_threshold_pct','gap_pp']:r[k]=float(r[k])
    assert abs(r['below_threshold_pct']-r['above_threshold_pct']-r['gap_pp'])<1e-8
assert [r['gap_pp'] for r in rows[1:]]==sorted([r['gap_pp'] for r in rows[1:]], reverse=True)
BG='#FAF9F5'; INK='#182C37'; MUTED='#52636B'; BLUE='#126497'; ORANGE='#A94314'; GRID='#DDE1DF'
W,H=1080,2450
fig=plt.figure(figsize=(W/100,H/100),dpi=100,facecolor=BG)
ax=fig.add_axes([0,0,1,1]);ax.set_xlim(0,W);ax.set_ylim(H,0);ax.axis('off')
plt.rcParams['svg.fonttype']='none'
plt.rcParams['svg.hashsalt']='meplar-eu-overcrowding-income-2025-v1'
plt.rcParams['font.family']='DejaVu Sans'
def txt(x,y,s,size=30,color=INK,weight='normal',ha='left'):
    return ax.text(x,y,s,fontsize=size*.72,color=color,weight=weight,ha=ha,va='baseline',fontfamily='DejaVu Sans')
def line(x1,y1,x2,y2,color=GRID,lw=1):ax.plot([x1,x2],[y1,y2],color=color,lw=lw,solid_capstyle='round')
def marker(x,y,below):ax.plot(x,y,marker='s' if below else 'o',markersize=10,markeredgecolor=BG,markeredgewidth=1.5,color=ORANGE if below else BLUE,zorder=5)
# Final chart: source figures and geometry retained; release labels reviewed.
txt(48,48,'MEPLAR  /  HOUSING · EU-SILC 2025',23,weight='bold')

txt(48,112,'Overcrowding across',53,weight='bold')
txt(48,173,"the EU’s income divide",53,weight='bold')
txt(48,226,'People living in overcrowded households, by income',29)
txt(48,266,'relative to the national at-risk-of-poverty threshold (%)',29)
marker(61,313,True);txt(82,323,'Below threshold',29,ORANGE,weight='bold')
marker(420,313,False);txt(441,323,'Above threshold',29,BLUE,weight='bold')
txt(48,375,'EU overall: 29.8% below vs 14.3% above',33,weight='bold')
txt(48,414,'Countries ordered by the below-minus-above gap.',26,MUTED)
X0,X1=350,1000
xx=lambda val:X0+(X1-X0)*val/60
# Country section separated from aggregate; common unbroken zero-based scale.
EUY=520; START=615; STEP=54
END=START+26*STEP
for v in range(0,61,10):
    x=xx(v);line(x,480,x,END+30,GRID,0.8);txt(x,468,str(v)+'%',25,MUTED,ha='center')
ax.add_patch(Rectangle((32,487),1016,67,facecolor='#E9EEEB',edgecolor='none',zorder=1))
for idx,r in enumerate(rows):
    y=EUY if idx==0 else START+(idx-1)*STEP
    name='EU · 27 countries' if idx==0 else r['country']
    if r['geo']=='LT':name+='*'
    if r['geo']=='LU':name+='†'
    if idx%2==0 and idx>0:ax.add_patch(Rectangle((32,y-27),1016,54,facecolor='#F0F1EC',edgecolor='none',zorder=0))
    txt(48,y+10,name,30,weight='bold' if idx==0 else 'normal')
    a,b=r['above_threshold_pct'],r['below_threshold_pct']
    line(xx(a),y,xx(b),y,'#8D9D9E',3)
    for val,below in [(a,False),(b,True)]:
        marker(xx(val),y,below)
        left=val==min(a,b)
        txt(xx(val)+(-17 if left else 17),y+10,f'{val:.1f}',30,ORANGE if below else BLUE,weight='bold' if idx==0 else 'normal',ha='right' if left else 'left')
line(48,572,1032,572,'#B8C4C2',1)
# Footer is part of image, not dependent on a Reddit caption.
base=END+75
line(48,base-25,1032,base-25,'#B8C4C2',1)
notes=[
('What the threshold means',29,INK,'bold'),
('60% of national median equivalised disposable income',26,MUTED,'normal'),
('after transfers. Each percentage is within its income group.',26,MUTED,'normal'),
('This is a relative income threshold, not a common cash line.',26,MUTED,'normal'),
('Private households; room-based standard, not floor area.',26,MUTED,'normal'),
('* Lithuania: provisional. † Luxembourg: break in series.',26,MUTED,'normal'),
('Descriptive point estimates; gaps are not causal effects.',26,MUTED,'normal'),
('Source: Eurostat · ilc_lvho05a · survey year 2025',26,INK,'bold'),
('Income reference: 2024. Data retrieved 29 Oct 2026.',26,MUTED,'normal'),
('Chart & selection: Meplar · Source figures unchanged.',23,MUTED,'normal'),
]
for i,(s,size,color,weight) in enumerate(notes):txt(48,base+i*34,s,size,color,weight)
assert base+(len(notes)-1)*34<H-20
fig.canvas.draw()
renderer=fig.canvas.get_renderer()
for t in ax.texts:
    bb=t.get_window_extent(renderer)
    assert bb.x0>=0 and bb.x1<=W and bb.y0>=0 and bb.y1<=H, (t.get_text(), bb.bounds)
fig.savefig(D/'chart.svg',facecolor=BG,metadata={'Date':'2026-10-29','Title': 'Overcrowding across the EU income divide', 'Description': 'Eurostat ilc_lvho05a, EU-SILC 2025. Below and above national at-risk-of-poverty threshold. All 27 EU countries plus published EU aggregate. Chart and selection by Meplar; descriptive point estimates.'})
fig.savefig(D/'chart.png',dpi=100,facecolor=BG)
plt.close(fig)
im=Image.open(D/'chart.png')
validation={'rows':len(rows),'countries':len(rows)-1,'dimensions':[W,H],'zero_based_scale':[0,60], 'EU_below':rows[0]['below_threshold_pct'],'EU_above':rows[0]['above_threshold_pct'],'flagged':[{k:r[k] for k in ['country','above_flag','below_flag']} for r in rows if r['above_flag'] or r['below_flag']], 'csv_sha256':hashlib.sha256((D/'chart-data.csv').read_bytes()).hexdigest()}
(D/'render-validation.json').write_text(json.dumps(validation,indent=2)+'\n')
print(json.dumps(validation,indent=2))

# Appended to the reviewed final desktop renderer; rows and D share its checked input.
from html import escape as _E
import subprocess as _subprocess
import xml.etree.ElementTree as _ET
_NS='http://www.w3.org/2000/svg'
_master=D/'chart.svg'
_raw=_master.read_text()
_start=_raw.index('<svg');_end=_raw.index('>',_start)
_raw=_raw[:_end]+ ' role="img" aria-labelledby="chart-title chart-description"'+_raw[_end:]
_end=_raw.index('>',_start)
_raw=_raw[:_end+1]+'\n<title id="chart-title">Overcrowding across the EU income divide, 2025</title><desc id="chart-description">All 27 EU countries and the published EU aggregate. Orange squares: below national at-risk-of-poverty threshold. Blue circles: above. EU 29.8% versus 14.3%. Lithuania provisional; Luxembourg break in time series. Croatia and Lithuania have reversed comparisons. Values and methods are also in the accompanying HTML table and CSV.</desc>'+_raw[_end+1:]
_master.write_text(_raw)
_parts=['<svg xmlns="http://www.w3.org/2000/svg" width="400" height="2200" viewBox="0 0 400 2200" role="img" aria-labelledby="mobile-title mobile-description">','<title id="mobile-title">EU overcrowding by income threshold, 2025</title><desc id="mobile-description">Responsive layout of all 27 EU countries plus the separate published EU aggregate, with identical data and a common unbroken zero to sixty percent axis. Below threshold: orange squares. Above threshold: blue circles. Croatia and Lithuania run in the opposite direction; Lithuania is provisional and Luxembourg has a break in series.</desc>','<rect width="400" height="2200" fill="#FAF9F5"/>']
def _text(x,y,s,size=14,color=INK,weight='400',anchor='start'):
 _parts.append(f'<text x="{x}" y="{y}" font-family="DejaVu Sans,sans-serif" font-size="{size}" font-weight="{weight}" fill="{color}" text-anchor="{anchor}">{_E(str(s))}</text>')
def _line(x1,y1,x2,y2,color=GRID,width=1):_parts.append(f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{color}" stroke-width="{width}"/>')
def _mark(x,y,below):
 _parts.append(f'<rect x="{x-4}" y="{y-4}" width="8" height="8" fill="{ORANGE}" stroke="{BG}"/>' if below else f'<circle cx="{x}" cy="{y}" r="4.5" fill="{BLUE}" stroke="{BG}"/>')
_text(20,28,'MEPLAR / HOUSING · EU-SILC 2025',12,weight='700')
_text(20,67,'Overcrowding across',29,weight='700');_text(20,103,'the EU’s income divide',29,weight='700')
for _n,_s in enumerate(['People in overcrowded households,','by income relative to the national','at-risk-of-poverty threshold (%)']):_text(20,137+22*_n,_s,16)
_mark(25,209,True);_text(38,214,'Below threshold',14,ORANGE,'700');_mark(220,209,False);_text(233,214,'Above threshold',14,BLUE,'700')
_text(20,251,'EU: 29.8% below / 14.3% above',18,weight='700')
_text(20,278,'27 countries, ordered by below-minus-above gap.',12,MUTED)
_x=lambda v:70+280*v/60
_EU=350;_START=432;_STEP=56;_END=_START+26*_STEP
for _v in range(0,61,10):
 _xx=_x(_v);_line(_xx,318,_xx,_END+9);_text(_xx,309,f'{_v}%',12,MUTED,anchor='middle')
for _n,_r in enumerate(rows):
 _y=_EU if _n==0 else _START+(_n-1)*_STEP
 if _n==0:_parts.append(f'<rect x="12" y="{_y-32}" width="376" height="51" fill="#E9EEEB"/>')
 elif _n%2==0:_parts.append(f'<rect x="12" y="{_y-32}" width="376" height="56" fill="#F0F1EC"/>')
 _name='EU · 27 countries' if _n==0 else _r['country']
 if _r['geo']=='LT':_name+='*'
 if _r['geo']=='LU':_name+='†'
 _text(20,_y-14,_name,15,weight='700' if _n==0 else '400')
 _a,_b=_r['above_threshold_pct'],_r['below_threshold_pct'];_line(_x(_a),_y,_x(_b),_y,'#8D9D9E',2)
 for _v,_below in [(_a,False),(_b,True)]:
  _left=_v==min(_a,_b);_mark(_x(_v),_y,_below);_text(round(_x(_v)+(-9 if _left else 9),3),_y+5,f'{_v:.1f}',14,ORANGE if _below else BLUE,anchor='end' if _left else 'start')
_line(20,385,380,385,'#B8C4C2')
_base=_END+52;_line(20,_base-20,380,_base-20,'#B8C4C2')
_notes=['Threshold: 60% of national median','equivalised disposable income after transfers.','Each percentage is within its income group.','National relative lines, not a common cash line.','Private households; room standard, not floor area.','* Lithuania: provisional. † Luxembourg: break.','Descriptive estimates, not causal effects.','Source: Eurostat · ilc_lvho05a · survey 2025','Income reference: 2024. Accessed 29 Oct 2026.','Chart & selection: Meplar. Figures unchanged.']
for _n,_s in enumerate(_notes):_text(20,_base+_n*23,_s,13,MUTED,weight='700' if _n==7 else '400')
assert _base+9*23<2180
_parts.append('</svg>');(D/'chart_mobile.svg').write_text('\n'.join(_parts)+'\n')
_ET.parse(D/'chart.svg');_ET.parse(D/'chart_mobile.svg')
_subprocess.run(['/opt/homebrew/bin/rsvg-convert','-w','800','-o',str(D/'chart_mobile.png'),str(D/'chart_mobile.svg')],check=True)
