Add dark mode palettes + Woodpecker CI pipeline

- Add 2 dark palettes (Nuit, Ocean) to DisplaySettings with full SVG
  theme tokens — all hardcoded SVG colors (grids, legends, text fills,
  pills, dot strokes, drag handles) replaced with reactive bindings
- Update scoped CSS to use var(--color-*) and var(--svg-*) throughout
- Add Woodpecker CI pipeline (.woodpecker.yml): build → docker push → deploy
- Add multi-stage Dockerfiles for backend (Python) and frontend (Nuxt)
- Add production docker-compose with Traefik labels + dev override
- Remove old single-stage Dockerfiles and root docker-compose.yml
- Update Makefile with docker-dev target
- Exclude data files (pdf, xls, ipynb) from git

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-23 21:36:31 +01:00
parent 5dc42af33e
commit 4ba5e78e58
16 changed files with 510 additions and 176 deletions

View File

@@ -65,7 +65,7 @@
<input type="number" v-model.number="citizenAbo" min="0" step="10" class="abo-input" />
</label>
<span class="zoom-separator"></span>
<span class="zoom-info" style="font-weight: 500; color: #475569;">
<span class="zoom-info" style="font-weight: 500;" :style="{ color: t.textCount }">
Recettes : {{ Math.round(recettes).toLocaleString() }}
</span>
</div>
@@ -92,16 +92,16 @@
<!-- Background -->
<rect :x="margin.left" :y="margin.top" :width="plotW" :height="plotH"
fill="#f8fafc" rx="4" />
:fill="t.plotBg" rx="4" />
<!-- Grid -->
<g>
<line v-for="v in gridVols" :key="'gv'+v"
:x1="cx(v)" :y1="margin.top" :x2="cx(v)" :y2="margin.top + plotH"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<line v-for="p in gridPrices" :key="'gp'+p"
:x1="margin.left" :y1="cy(p)" :x2="margin.left + plotW" :y2="cy(p)"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<!-- Volume labels (bottom) -->
<text v-for="v in gridVols" :key="'lv'+v"
:x="cx(v)" :y="margin.top + plotH + 16" text-anchor="middle"
@@ -113,11 +113,11 @@
<text v-for="bk in visibleBuckets30Main" :key="'hc'+bk.low"
:x="(cx(bk.low) + cx(bk.high)) / 2"
:y="margin.top + plotH + 32"
text-anchor="middle" font-size="9.5" fill="#64748b" font-weight="500">
text-anchor="middle" font-size="9.5" :fill="t.textMuted" font-weight="500">
{{ bk.count }}
</text>
<text :x="margin.left + plotW + 6" :y="margin.top + plotH + 32"
text-anchor="start" font-size="8.5" fill="#64748b">foyers/30</text>
text-anchor="start" font-size="8.5" :fill="t.textMuted">foyers/30</text>
</g>
<!-- Price labels (RIGHT side, since Y axis is on right at vol=0) -->
<text v-for="p in gridPrices" :key="'lp'+p"
@@ -160,11 +160,11 @@
<!-- Bezier curve: population (blue gradient, thick) -->
<path :d="tier1Path" fill="none"
:stroke="showHouseholds ? '#cbd5e1' : '#2563eb'"
:stroke="showHouseholds ? (isDark ? '#475569' : '#cbd5e1') : '#2563eb'"
:stroke-width="showHouseholds ? 2 : 3.5" stroke-linecap="round" />
<!-- Bezier curve: cas exceptionnels (orange) -->
<path :d="tier2Path" fill="none"
:stroke="showHouseholds ? '#cbd5e1' : '#ea580c'"
:stroke="showHouseholds ? (isDark ? '#475569' : '#cbd5e1') : '#ea580c'"
:stroke-width="showHouseholds ? 2 : 2.5" stroke-linecap="round" />
<!-- Household dots on curves -->
@@ -174,7 +174,7 @@
r="3.5"
:fill="hh.volume <= bp.vinf ? '#2563eb' : '#ea580c'"
:opacity="0.65"
stroke="white" stroke-width="0.8"
:stroke="t.plotBg" stroke-width="0.8"
/>
</g>
@@ -185,8 +185,8 @@
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4 4" />
<!-- p0 label (pill style) -->
<rect :x="cx(bp.vinf) + 8" :y="cy(localP0) - 18" width="180" height="20" rx="10" fill="white" stroke="#e2e8f0" />
<text :x="cx(bp.vinf) + 98" :y="cy(localP0) - 5" text-anchor="middle" font-size="10.5" fill="#1e293b" font-weight="600">
<rect :x="cx(bp.vinf) + 8" :y="cy(localP0) - 18" width="180" height="20" rx="10" :fill="t.pillBg" :stroke="t.pillBorder" />
<text :x="cx(bp.vinf) + 98" :y="cy(localP0) - 5" text-anchor="middle" font-size="10.5" :fill="t.pillText" font-weight="600">
Prix au palier : {{ localP0.toFixed(2) }} /
</text>
@@ -205,7 +205,7 @@
<circle
:cx="margin.left + 14" :cy="cy(medianPrice)"
:r="dragging === 'medianBar' ? 10 : 7"
fill="#059669" stroke="white" :stroke-width="dragging === 'medianBar' ? 3 : 2"
fill="#059669" :stroke="t.plotBg" :stroke-width="dragging === 'medianBar' ? 3 : 2"
class="drag-handle"
@mousedown.prevent="startDrag('medianBar')"
@touchstart.prevent="startDrag('medianBar')"
@@ -219,7 +219,7 @@
<circle v-for="(pt, key) in dragPoints" :key="key"
:cx="cx(pt.x)" :cy="cy(pt.y)"
:r="dragging === key ? 10 : 7"
:fill="ptColors[key]" stroke="white" :stroke-width="dragging === key ? 3 : 2"
:fill="ptColors[key]" :stroke="t.plotBg" :stroke-width="dragging === key ? 3 : 2"
class="drag-handle"
@mousedown.prevent="startDrag(key)"
@touchstart.prevent="startDrag(key)"
@@ -232,18 +232,18 @@
<!-- Legend box (top-right) -->
<g :transform="`translate(${margin.left + plotW - 232}, ${margin.top + 8})`">
<rect x="0" y="0" width="220" :height="citizenAbo > 0 ? 80 : 62" rx="6" fill="white" fill-opacity="0.92" stroke="#e2e8f0" />
<rect x="0" y="0" width="220" :height="citizenAbo > 0 ? 80 : 62" rx="6" :fill="t.legendBg" fill-opacity="0.92" :stroke="t.legendBorder" />
<line x1="10" y1="14" x2="28" y2="14" stroke="#2563eb" stroke-width="3" stroke-linecap="round" />
<text x="34" y="18" font-size="11" fill="#334155">Consommations foyers</text>
<text x="34" y="18" font-size="11" :fill="t.text">Consommations foyers</text>
<line x1="10" y1="32" x2="28" y2="32" stroke="#ea580c" stroke-width="2.5" stroke-linecap="round" />
<text x="34" y="36" font-size="11" fill="#334155">Consommations exceptionnelles</text>
<text x="34" y="36" font-size="11" :fill="t.text">Consommations exceptionnelles</text>
<line x1="10" y1="50" x2="28" y2="50" stroke="#059669" stroke-width="2" stroke-dasharray="6 3" stroke-linecap="round" />
<text x="34" y="54" font-size="11" fill="#334155">
<text x="34" y="54" font-size="11" :fill="t.text">
Prix median ({{ medianPrice.toFixed(2) }}/)
</text>
<g v-if="citizenAbo > 0">
<line x1="10" y1="68" x2="28" y2="68" stroke="#059669" stroke-width="2" stroke-dasharray="3 2" stroke-linecap="round" />
<text x="34" y="72" font-size="11" fill="#334155">Prix moyen avec abo.</text>
<text x="34" y="72" font-size="11" :fill="t.text">Prix moyen avec abo.</text>
</g>
</g>
</svg>
@@ -268,7 +268,7 @@
class="form-input" style="flex: 1; text-transform: uppercase; font-family: monospace; letter-spacing: 0.12em; font-size: 0.85rem;" />
<button type="submit" class="btn btn-primary" :disabled="authLoading" style="padding: 0.35rem 0.75rem;">OK</button>
</form>
<p v-if="isDev" style="margin-top: 0.4rem; padding: 0.3rem 0.5rem; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 5px; font-size: 0.7rem;">
<p v-if="isDev" :style="{ marginTop: '0.4rem', padding: '0.3rem 0.5rem', background: t.devBg, border: '1px solid ' + t.devBorder, borderRadius: '5px', fontSize: '0.7rem', color: isDark ? '#fcd34d' : 'inherit' }">
<strong>Dev:</strong> QPF5L9ZK (60)
</p>
</div>
@@ -337,13 +337,13 @@
<div class="chart-container">
<svg :viewBox="`0 0 ${histW} ${histH}`" preserveAspectRatio="xMidYMid meet">
<rect :x="histMargin.left" :y="histMargin.top" :width="histPlotW" :height="histPlotH"
fill="#f8fafc" rx="4" />
:fill="t.plotBg" rx="4" />
<!-- Y grid lines -->
<g>
<template v-for="y in histGridY" :key="'hgy'+y">
<line :x1="histMargin.left" :y1="histCy(y)" :x2="histMargin.left + histPlotW" :y2="histCy(y)"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<text :x="histMargin.left - 6" :y="histCy(y) + 4" text-anchor="end" class="axis-label-sm">{{ y }}</text>
</template>
</g>
@@ -363,7 +363,7 @@
<text v-if="bk.count > 0"
:x="(histCx(bk.low) + histCx(bk.high)) / 2"
:y="histCy(bk.count) - 4"
text-anchor="middle" font-size="10" fill="#334155" font-weight="600">
text-anchor="middle" font-size="10" :fill="t.text" font-weight="600">
{{ bk.count }}
</text>
</g>
@@ -387,11 +387,11 @@
<!-- Legend (top-right) -->
<g :transform="`translate(${histMargin.left + histPlotW - 222}, ${histMargin.top + 6})`">
<rect x="0" y="0" width="210" height="44" rx="6" fill="white" fill-opacity="0.9" stroke="#e2e8f0" />
<rect x="0" y="0" width="210" height="44" rx="6" :fill="t.legendBg" fill-opacity="0.9" :stroke="t.legendBorder" />
<rect x="10" y="8" width="14" height="10" rx="2" fill="#2563eb" opacity="0.7" />
<text x="30" y="17" font-size="10" fill="#334155">Consommations foyers</text>
<text x="30" y="17" font-size="10" :fill="t.text">Consommations foyers</text>
<rect x="10" y="26" width="14" height="10" rx="2" fill="#ea580c" opacity="0.7" />
<text x="30" y="35" font-size="10" fill="#334155">Consommations exceptionnelles</text>
<text x="30" y="35" font-size="10" :fill="t.text">Consommations exceptionnelles</text>
</g>
<!-- Axis titles -->
@@ -409,7 +409,7 @@
<!--
INDICATEURS CLES
-->
<div v-if="params" class="key-metrics-banner" style="margin-bottom: 1.5rem;">
<div v-if="params" class="key-metrics-banner" :style="{ marginBottom: '1.5rem', background: t.metricBg }">
<div class="key-metric key-metric-input">
<span class="key-metric-value">{{ params.recettes.toLocaleString() }} </span>
<span class="key-metric-label">Recettes cibles</span>
@@ -469,7 +469,7 @@
</div>
</div>
<div style="border-top: 1px solid var(--color-border); padding-top: 0.75rem; margin-top: 0.5rem;">
<p style="font-size: 0.82rem; color: #475569; font-weight: 600; margin-bottom: 0.35rem;">Votre vote donne :</p>
<p style="font-size: 0.82rem; color: var(--color-text-muted); font-weight: 600; margin-bottom: 0.35rem;">Votre vote donne :</p>
<div class="vote-result-row">
<span>un prix au palier de</span>
<strong>{{ localP0.toFixed(2) }} /</strong>
@@ -520,16 +520,16 @@
<div class="baseline-charts">
<!-- Left: Facture totale -->
<div class="chart-container">
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem; color: #475569;">Facture totale ()</h4>
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem; color: var(--color-text-muted);">Facture totale ()</h4>
<svg :viewBox="`0 0 ${W2} ${H2}`" preserveAspectRatio="xMidYMid meet">
<rect :x="margin2.left" :y="margin2.top" :width="plotW2" :height="plotH2" fill="#f8fafc" rx="3" />
<rect :x="margin2.left" :y="margin2.top" :width="plotW2" :height="plotH2" :fill="t.plotBg" rx="3" />
<g>
<line v-for="v in gridVols2" :key="'bg1v'+v"
:x1="cx2(v)" :y1="margin2.top" :x2="cx2(v)" :y2="margin2.top + plotH2"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<line v-for="b in gridBills" :key="'bg1b'+b"
:x1="margin2.left" :y1="cy2bill(b)" :x2="margin2.left + plotW2" :y2="cy2bill(b)"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<text v-for="v in gridVols2" :key="'bg1lv'+v"
:x="cx2(v)" :y="margin2.top + plotH2 + 14" text-anchor="middle" class="axis-label-sm">{{ v }}</text>
<text v-for="b in gridBills" :key="'bg1lb'+b"
@@ -539,11 +539,11 @@
<text v-for="bk in visibleBuckets30Baseline" :key="'b1hc'+bk.low"
:x="(cx2(bk.low) + cx2(bk.high)) / 2"
:y="margin2.top + plotH2 + 28"
text-anchor="middle" font-size="9" fill="#64748b" font-weight="500">
text-anchor="middle" font-size="9" :fill="t.textMuted" font-weight="500">
{{ bk.count }}
</text>
<text :x="margin2.left + plotW2 + 4" :y="margin2.top + plotH2 + 28"
text-anchor="start" font-size="8" fill="#64748b">foy.</text>
text-anchor="start" font-size="8" :fill="t.textMuted">foy.</text>
</g>
</g>
<defs>
@@ -553,35 +553,35 @@
</defs>
<g clip-path="url(#baseline-clip-1)">
<polyline :points="baselineBillRP" fill="none"
:stroke="showHouseholds ? '#cbd5e1' : '#2563eb'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" />
:stroke="showHouseholds ? (isDark ? '#475569' : '#cbd5e1') : '#2563eb'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" />
<polyline v-if="differentiatedTariff" :points="baselineBillRS" fill="none"
:stroke="showHouseholds ? '#e2e8f0' : '#f59e0b'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" stroke-dasharray="6 3" />
:stroke="showHouseholds ? (isDark ? '#334155' : '#e2e8f0') : '#f59e0b'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" stroke-dasharray="6 3" />
<!-- Household dots: bill = abo + p0_linear * volume -->
<g v-if="showHouseholds && householdVolumes.length">
<circle v-for="(hh, i) in householdDotsBill" :key="'hdb'+i"
:cx="cx2(hh.volume)" :cy="cy2bill(hh.bill)"
r="2.5" fill="#2563eb" opacity="0.5"
stroke="white" stroke-width="0.5"
:stroke="t.plotBg" stroke-width="0.5"
/>
</g>
</g>
<text :x="W2/2" :y="margin2.top + plotH2 + 42" text-anchor="middle" class="axis-label-sm"> reduire</text>
<!-- Legend when differentiated (top-right) -->
<g v-if="differentiatedTariff" :transform="`translate(${margin2.left + plotW2 - 92}, ${margin2.top + 4})`">
<rect x="0" y="0" width="80" height="32" rx="4" fill="white" fill-opacity="0.9" stroke="#e2e8f0" />
<rect x="0" y="0" width="80" height="32" rx="4" :fill="t.legendBg" fill-opacity="0.9" :stroke="t.legendBorder" />
<line x1="6" y1="11" x2="18" y2="11" stroke="#2563eb" stroke-width="2" />
<text x="22" y="14" font-size="8" fill="#334155">RP/PRO</text>
<text x="22" y="14" font-size="8" :fill="t.text">RP/PRO</text>
<line x1="6" y1="24" x2="18" y2="24" stroke="#f59e0b" stroke-width="2" stroke-dasharray="4 2" />
<text x="22" y="27" font-size="8" fill="#334155">RS</text>
<text x="22" y="27" font-size="8" :fill="t.text">RS</text>
</g>
</svg>
</div>
<!-- Right: Prix au m3 -->
<div class="chart-container">
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem; color: #475569;">Prix au ()</h4>
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem; color: var(--color-text-muted);">Prix au ()</h4>
<svg :viewBox="`0 0 ${W2} ${H2}`" preserveAspectRatio="xMidYMid meet">
<rect :x="margin2.left" :y="margin2.top" :width="plotW2" :height="plotH2" fill="#f8fafc" rx="3" />
<rect :x="margin2.left" :y="margin2.top" :width="plotW2" :height="plotH2" :fill="t.plotBg" rx="3" />
<defs>
<clipPath id="baseline-clip-2">
<rect :x="margin2.left" :y="margin2.top" :width="plotW2" :height="plotH2" />
@@ -590,10 +590,10 @@
<g>
<line v-for="v in gridVols2" :key="'bg2v'+v"
:x1="cx2(v)" :y1="margin2.top" :x2="cx2(v)" :y2="margin2.top + plotH2"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<line v-for="p in gridPrices2" :key="'bg2p'+p"
:x1="margin2.left" :y1="cy2price(p)" :x2="margin2.left + plotW2" :y2="cy2price(p)"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<text v-for="v in gridVols2" :key="'bg2lv'+v"
:x="cx2(v)" :y="margin2.top + plotH2 + 14" text-anchor="middle" class="axis-label-sm">{{ v }}</text>
<text v-for="p in gridPrices2" :key="'bg2lp'+p"
@@ -603,41 +603,41 @@
<text v-for="bk in visibleBuckets30Baseline" :key="'b2hc'+bk.low"
:x="(cx2(bk.low) + cx2(bk.high)) / 2"
:y="margin2.top + plotH2 + 28"
text-anchor="middle" font-size="9" fill="#64748b" font-weight="500">
text-anchor="middle" font-size="9" :fill="t.textMuted" font-weight="500">
{{ bk.count }}
</text>
<text :x="margin2.left + plotW2 + 4" :y="margin2.top + plotH2 + 28"
text-anchor="start" font-size="8" fill="#64748b">foy.</text>
text-anchor="start" font-size="8" :fill="t.textMuted">foy.</text>
</g>
</g>
<g clip-path="url(#baseline-clip-2)">
<polyline :points="baselinePriceRP" fill="none"
:stroke="showHouseholds ? '#cbd5e1' : '#2563eb'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" />
:stroke="showHouseholds ? (isDark ? '#475569' : '#cbd5e1') : '#2563eb'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" />
<polyline v-if="differentiatedTariff" :points="baselinePriceRS" fill="none"
:stroke="showHouseholds ? '#e2e8f0' : '#f59e0b'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" stroke-dasharray="6 3" />
:stroke="showHouseholds ? (isDark ? '#334155' : '#e2e8f0') : '#f59e0b'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" stroke-dasharray="6 3" />
<!-- Household dots: price = abo/v + p0_linear -->
<g v-if="showHouseholds && householdVolumes.length">
<circle v-for="(hh, i) in householdDotsBaselinePrice" :key="'hdp'+i"
:cx="cx2(hh.volume)" :cy="cy2price(hh.price)"
r="2.5" fill="#2563eb" opacity="0.5"
stroke="white" stroke-width="0.5"
:stroke="t.plotBg" stroke-width="0.5"
/>
</g>
<line :x1="cx2(baselineVolMax)" :y1="cy2price(curveData.p0_linear)" :x2="cx2(0)" :y2="cy2price(curveData.p0_linear)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4 4" />
</g>
<text :x="cx2(0) + 5" :y="cy2price(curveData.p0_linear) - 6" text-anchor="start"
font-size="10" fill="#475569" font-weight="500">
font-size="10" :fill="t.textCount" font-weight="500">
Prix uniforme : {{ curveData.p0_linear?.toFixed(2) }}/
</text>
<text :x="W2/2" :y="margin2.top + plotH2 + 42" text-anchor="middle" class="axis-label-sm"> reduire</text>
<!-- Legend when differentiated (top-right) -->
<g v-if="differentiatedTariff" :transform="`translate(${margin2.left + plotW2 - 92}, ${margin2.top + 4})`">
<rect x="0" y="0" width="80" height="32" rx="4" fill="white" fill-opacity="0.9" stroke="#e2e8f0" />
<rect x="0" y="0" width="80" height="32" rx="4" :fill="t.legendBg" fill-opacity="0.9" :stroke="t.legendBorder" />
<line x1="6" y1="11" x2="18" y2="11" stroke="#2563eb" stroke-width="2" />
<text x="22" y="14" font-size="8" fill="#334155">RP/PRO</text>
<text x="22" y="14" font-size="8" :fill="t.text">RP/PRO</text>
<line x1="6" y1="24" x2="18" y2="24" stroke="#f59e0b" stroke-width="2" stroke-dasharray="4 2" />
<text x="22" y="27" font-size="8" fill="#334155">RS</text>
<text x="22" y="27" font-size="8" :fill="t.text">RS</text>
</g>
</svg>
</div>
@@ -645,22 +645,22 @@
<!-- Full-width: Prix marginal au + distribution foyers -->
<div style="margin-top: 1rem;">
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem; color: #475569;">
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem; color: var(--color-text-muted);">
Prix au situation actuelle
</h4>
<div class="chart-container">
<svg :viewBox="`0 0 ${Wmarg} ${Hmarg}`" preserveAspectRatio="xMidYMid meet">
<!-- Background -->
<rect :x="margMargin.left" :y="margMargin.top" :width="margPlotW" :height="margPlotH"
fill="#f8fafc" rx="4" />
:fill="t.plotBg" rx="4" />
<defs>
<clipPath id="marg-clip">
<rect :x="margMargin.left" :y="margMargin.top" :width="margPlotW" :height="margPlotH" />
</clipPath>
<linearGradient id="bar-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2563eb" stop-opacity="0.35" />
<stop offset="100%" stop-color="#2563eb" stop-opacity="0.12" />
<stop offset="0%" :stop-color="isDark ? '#60a5fa' : '#2563eb'" stop-opacity="0.35" />
<stop offset="100%" :stop-color="isDark ? '#60a5fa' : '#2563eb'" stop-opacity="0.12" />
</linearGradient>
</defs>
@@ -668,10 +668,10 @@
<g>
<line v-for="v in margGridVols" :key="'mgv'+v"
:x1="margCx(v)" :y1="margMargin.top" :x2="margCx(v)" :y2="margMargin.top + margPlotH"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<line v-for="p in margGridPrices" :key="'mgp'+p"
:x1="margMargin.left" :y1="margCyPrice(p)" :x2="margMargin.left + margPlotW" :y2="margCyPrice(p)"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<!-- Volume labels -->
<text v-for="v in margGridVols" :key="'mglv'+v"
:x="margCx(v)" :y="margMargin.top + margPlotH + 18" text-anchor="middle" class="axis-label-sm">{{ v }}</text>
@@ -683,7 +683,7 @@
<!-- Household count labels (left axis) -->
<text v-for="c in margGridCounts" :key="'mgc'+c"
:x="margMargin.left - 4" :y="margCyCount(c) + 3" text-anchor="end"
font-size="9.5" fill="#64748b" font-weight="500">{{ c }}</text>
font-size="9.5" :fill="t.textMuted" font-weight="500">{{ c }}</text>
</g>
<!-- Household histogram bars (behind the curve) -->
@@ -699,14 +699,14 @@
<text v-if="bk.count > 0"
:x="(margCx(bk.low) + margCx(bk.high)) / 2"
:y="margCyCount(bk.count) - 3"
text-anchor="middle" font-size="9.5" fill="#475569" font-weight="600">
text-anchor="middle" font-size="9.5" :fill="t.textCount" font-weight="600">
{{ bk.count }}
</text>
</g>
<!-- Price line (marginal) -->
<polyline :points="margPriceLine" fill="none"
:stroke="showHouseholds ? '#cbd5e1' : '#1e40af'"
:stroke="showHouseholds ? (isDark ? '#475569' : '#cbd5e1') : (isDark ? '#93c5fd' : '#1e40af')"
:stroke-width="showHouseholds ? 1.5 : 2.5" stroke-linecap="round" />
<!-- Household dots on marginal price chart -->
@@ -714,8 +714,8 @@
<circle v-for="(hh, i) in householdDotsMarg" :key="'hm'+i"
:cx="margCx(hh.volume)" :cy="margCyPrice(hh.price)"
r="3"
fill="#1e40af" opacity="0.5"
stroke="white" stroke-width="0.6"
:fill="isDark ? '#93c5fd' : '#1e40af'" opacity="0.5"
:stroke="t.plotBg" stroke-width="0.6"
/>
</g>
@@ -726,7 +726,7 @@
<!-- p0 label -->
<text :x="margMargin.left + margPlotW + 4" :y="margCyPrice(curveData.p0_linear) - 5" text-anchor="start"
font-size="9" fill="#475569" font-weight="600">
font-size="9" :fill="t.textCount" font-weight="600">
{{ curveData.p0_linear?.toFixed(2) }}/
</text>
@@ -746,11 +746,11 @@
<!-- Legend -->
<g :transform="`translate(${margMargin.left + 8}, ${margMargin.top + 6})`">
<rect x="0" y="0" width="230" height="44" rx="6" fill="white" fill-opacity="0.92" stroke="#e2e8f0" />
<line x1="10" y1="14" x2="28" y2="14" stroke="#1e40af" stroke-width="2.5" stroke-linecap="round" />
<text x="34" y="18" font-size="10" fill="#334155" font-weight="500">Prix au avec abonnement (/)</text>
<rect x="10" y="25" width="14" height="10" rx="2" fill="#2563eb" fill-opacity="0.25" />
<text x="34" y="34" font-size="10" fill="#334155" font-weight="500">Foyers par tranche de 30</text>
<rect x="0" y="0" width="230" height="44" rx="6" :fill="t.legendBg" fill-opacity="0.92" :stroke="t.legendBorder" />
<line x1="10" y1="14" x2="28" y2="14" :stroke="isDark ? '#93c5fd' : '#1e40af'" stroke-width="2.5" stroke-linecap="round" />
<text x="34" y="18" font-size="10" :fill="t.text" font-weight="500">Prix au avec abonnement (/)</text>
<rect x="10" y="25" width="14" height="10" rx="2" :fill="isDark ? '#60a5fa' : '#2563eb'" fill-opacity="0.25" />
<text x="34" y="34" font-size="10" :fill="t.text" font-weight="500">Foyers par tranche de 30</text>
</g>
</svg>
</div>
@@ -787,6 +787,26 @@ import {
const route = useRoute()
const authStore = useAuthStore()
const api = useApi()
const isDark = useState('theme-dark', () => false)
// Reactive SVG theme colors (light/dark aware)
const t = computed(() => isDark.value ? {
plotBg: '#1e293b', grid: '#334155', legendBg: '#1e293b', legendBorder: '#475569',
text: '#cbd5e1', textDark: '#f1f5f9', textMuted: '#94a3b8', textCount: '#94a3b8',
pillBg: '#1e293b', pillBorder: '#475569', pillText: '#f1f5f9',
gradStart: 'rgba(96,165,250,0.3)', gradEnd: 'rgba(96,165,250,0.08)',
metricBg: 'linear-gradient(135deg, #1e293b, #162032)',
highlightBg: 'rgba(52,211,153,0.1)',
devBg: '#451a03', devBorder: '#92400e',
} : {
plotBg: '#f8fafc', grid: '#e2e8f0', legendBg: 'white', legendBorder: '#e2e8f0',
text: '#334155', textDark: '#1e293b', textMuted: '#64748b', textCount: '#475569',
pillBg: 'white', pillBorder: '#e2e8f0', pillText: '#1e293b',
gradStart: 'rgba(37,99,235,0.35)', gradEnd: 'rgba(37,99,235,0.12)',
metricBg: 'linear-gradient(135deg, #eff6ff, #f0fdf4)',
highlightBg: 'rgba(5,150,105,0.06)',
devBg: '#fef3c7', devBorder: '#f59e0b',
})
const slug = route.params.slug as string
const commune = ref<any>(null)
@@ -1576,8 +1596,8 @@ function renderMarkdown(md: string): string {
/* ── Chart cards ── */
.chart-card {
background: white;
border: 1px solid #e2e8f0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: clamp(8px, 2vw, 12px);
padding: clamp(0.75rem, 2vw, 1.5rem);
box-shadow: 0 1px 4px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.02);
@@ -1595,13 +1615,13 @@ function renderMarkdown(md: string): string {
.chart-title {
font-size: clamp(0.95rem, 2.5vw, 1.1rem);
font-weight: 700;
color: #1e293b;
color: var(--color-text);
margin-bottom: 0.15rem;
}
.chart-subtitle {
font-size: clamp(0.72rem, 1.8vw, 0.82rem);
color: #64748b;
color: var(--color-text-muted);
}
/* ── Zoom bar ── */
@@ -1615,7 +1635,7 @@ function renderMarkdown(md: string): string {
.zoom-info {
font-size: 0.72rem;
color: #94a3b8;
color: var(--color-text-muted);
margin-left: 0.25rem;
}
@@ -1639,7 +1659,7 @@ function renderMarkdown(md: string): string {
.zoom-separator {
width: 1px;
height: 18px;
background: #e2e8f0;
background: var(--color-border);
margin: 0 0.25rem;
}
@media (max-width: 480px) {
@@ -1658,12 +1678,13 @@ function renderMarkdown(md: string): string {
.abo-input {
width: 56px;
padding: 0.15rem 0.35rem;
border: 1px solid #cbd5e1;
border: 1px solid var(--color-border);
border-radius: 5px;
font-size: 0.78rem;
text-align: right;
font-family: monospace;
background: white;
background: var(--color-surface);
color: var(--color-text);
}
.abo-input:focus {
outline: none;
@@ -1672,9 +1693,9 @@ function renderMarkdown(md: string): string {
}
/* ── SVG axes ── */
.axis-label { font-size: 10.5px; fill: #475569; font-weight: 500; font-family: system-ui, -apple-system, sans-serif; }
.axis-label-sm { font-size: 10px; fill: #64748b; font-weight: 500; font-family: system-ui, -apple-system, sans-serif; }
.axis-title { font-size: 11px; fill: #64748b; font-weight: 600; font-family: system-ui, -apple-system, sans-serif; }
.axis-label { font-size: 10.5px; fill: var(--svg-text); font-weight: 500; font-family: system-ui, -apple-system, sans-serif; }
.axis-label-sm { font-size: 10px; fill: var(--svg-text-light); font-weight: 500; font-family: system-ui, -apple-system, sans-serif; }
.axis-title { font-size: 11px; fill: var(--svg-text-light); font-weight: 600; font-family: system-ui, -apple-system, sans-serif; }
/* ── Chart containers ── */
.chart-container svg {
@@ -1757,10 +1778,10 @@ function renderMarkdown(md: string): string {
.section-title {
font-size: clamp(1rem, 2.5vw, 1.15rem);
font-weight: 700;
color: #1e293b;
color: var(--color-text);
margin: clamp(1rem, 3vw, 1.5rem) 0 clamp(0.75rem, 2vw, 1rem);
padding-bottom: 0.4rem;
border-bottom: 2px solid #e2e8f0;
border-bottom: 2px solid var(--color-border);
}
/* ── Params + impacts ── */
@@ -1804,8 +1825,7 @@ function renderMarkdown(md: string): string {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: clamp(0.5rem, 2vw, 1rem);
background: linear-gradient(135deg, #eff6ff, #f0fdf4);
border: 1px solid #e2e8f0;
border: 1px solid var(--color-border);
border-radius: clamp(8px, 2vw, 12px);
padding: clamp(0.75rem, 2vw, 1.25rem) clamp(0.75rem, 2vw, 1.5rem);
}
@@ -1823,11 +1843,11 @@ function renderMarkdown(md: string): string {
.key-metric-value {
font-size: clamp(1.1rem, 3vw, 1.4rem);
font-weight: 700;
color: #1e293b;
color: var(--color-text);
}
.key-metric-label {
font-size: clamp(0.68rem, 1.6vw, 0.78rem);
color: #64748b;
color: var(--color-text-muted);
font-weight: 500;
}
.key-metric-tag {
@@ -1837,14 +1857,15 @@ function renderMarkdown(md: string): string {
letter-spacing: 0.05em;
padding: 0.1rem 0.5rem;
border-radius: 20px;
background: #f1f5f9;
color: #64748b;
border: 1px solid #e2e8f0;
background: var(--color-bg);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.key-metric-tag-calc {
background: #eff6ff;
color: #2563eb;
border-color: #bfdbfe;
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
opacity: 0.85;
}
.key-metric-input {
opacity: 0.85;
@@ -1866,12 +1887,12 @@ function renderMarkdown(md: string): string {
align-items: center;
font-size: clamp(0.72rem, 1.8vw, 0.8rem);
padding: 0.2rem 0;
color: #475569;
color: var(--color-text-muted);
}
.vote-result-row strong {
font-family: monospace;
font-size: 0.88rem;
color: #1e293b;
color: var(--color-text);
}
/* ── Alerts ── */