Files
Yvv 4ba5e78e58 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>
2026-02-23 21:36:31 +01:00

1920 lines
81 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div v-if="commune">
<!-- 1. Header -->
<div class="page-header">
<NuxtLink to="/" style="color: var(--color-text-muted); font-size: 0.875rem;">&larr; Toutes les communes</NuxtLink>
<h1>{{ commune.name }}</h1>
<p style="color: var(--color-text-muted);">{{ commune.description }}</p>
</div>
<!-- Loading -->
<div v-if="loading" class="card" style="text-align: center; padding: 3rem;">
<div class="spinner" style="margin: 0 auto;"></div>
<p style="margin-top: 1rem; color: var(--color-text-muted);">Chargement...</p>
</div>
<template v-else-if="curveData">
<!--
COURBE INTERACTIVE + SIDEBAR AUTH/VOTE
-->
<div class="main-layout">
<!-- LEFT: interactive chart -->
<div class="main-layout-chart">
<div class="chart-card">
<div class="chart-header">
<div>
<h3 class="chart-title">Tarification progressive Prix au m<sup>3</sup></h3>
<p class="chart-subtitle">
Deplacez les poignees pour ajuster la courbe. Le prix au palier s'ajuste pour equilibrer les recettes.
La barre verte indique le prix median — deplacez-la pour changer les recettes cibles.
</p>
<p v-if="differentiatedTariff" class="chart-subtitle" style="margin-top: 0.15rem; font-style: italic;">
Hors abonnement. L'abonnement differe selon le statut : {{ abop }} (RP/PRO) / {{ abos }} (RS).
</p>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<label class="toggle-label">
<input type="checkbox" v-model="showOutliers" />
Courbes des votes
</label>
<label class="toggle-label">
<input type="checkbox" v-model="showHouseholds" />
Voir les foyers
</label>
<span v-if="curveData.has_votes" class="badge badge-green">
{{ curveData.vote_count }} vote(s)
</span>
<span v-else class="badge badge-amber">Courbe par defaut</span>
</div>
</div>
<!-- Zoom controls + abonnement -->
<div class="zoom-bar">
<button class="btn btn-secondary btn-xs" @click="zoomPreset('tier1')" title="Zoom population">Population</button>
<button class="btn btn-secondary btn-xs" @click="zoomPreset('tier2')" title="Zoom cas exceptionnels">Cas exceptionnels</button>
<button class="btn btn-secondary btn-xs" @click="zoomIn">+</button>
<button class="btn btn-secondary btn-xs" @click="zoomOut">&minus;</button>
<button class="btn btn-secondary btn-xs" @click="zoomReset">Reset</button>
<span class="zoom-info">
{{ zoomVolMin.toFixed(0) }}{{ zoomVolMax.toFixed(0) }}
</span>
<span class="zoom-separator"></span>
<label class="abo-input-label">
Abonnement
<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;" :style="{ color: t.textCount }">
Recettes : {{ Math.round(recettes).toLocaleString() }}
</span>
</div>
<!-- SVG: full-width, X axis inverted (high vol left, 0 right) -->
<div class="chart-container chart-main">
<svg
ref="svgRef"
:viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="xMidYMid meet"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
@touchmove.prevent="onTouchMove"
@touchend="onMouseUp"
@wheel.prevent="onWheel"
>
<!-- Clip path for plot area -->
<defs>
<clipPath id="plot-clip">
<rect :x="margin.left" :y="margin.top" :width="plotW" :height="plotH" />
</clipPath>
</defs>
<!-- Background -->
<rect :x="margin.left" :y="margin.top" :width="plotW" :height="plotH"
: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="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="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"
class="axis-label">
{{ v }}
</text>
<!-- Household counts per 30 bracket below axis -->
<g v-if="distBuckets30.length">
<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="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="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"
:x="margin.left + plotW + 6" :y="cy(p) + 4" text-anchor="start"
class="axis-label">
{{ p.toFixed(p < 1 ? 1 : 0) }}
</text>
<!-- Axis titles -->
<text :x="W / 2" :y="margin.top + plotH + 48" text-anchor="middle" class="axis-title">
Consommation () reduire
</text>
<text :x="W - 4" :y="margin.top - 6" text-anchor="end" class="axis-title">
/
</text>
</g>
<!-- Clipped plot content -->
<g clip-path="url(#plot-clip)">
<!-- Outlier vote curves (faisceau) -->
<g v-if="showOutliers && outlierVotes.length">
<template v-for="(vote, i) in outlierVotes" :key="'ov'+i">
<path :d="outlierPath(vote, 1)" fill="none" stroke="#3b82f6" stroke-width="2" opacity="0.45" />
<path :d="outlierPath(vote, 2)" fill="none" stroke="#f87171" stroke-width="2" opacity="0.45" />
</template>
</g>
<!-- Tangent lines (control arms) -->
<line :x1="cx(cp.p1.x)" :y1="cy(cp.p1.y)" :x2="cx(cp.p2.x)" :y2="cy(cp.p2.y)"
stroke="#93c5fd" stroke-width="1" stroke-dasharray="6 3" opacity="0.6" />
<line :x1="cx(cp.p3.x)" :y1="cy(cp.p3.y)" :x2="cx(cp.p4.x)" :y2="cy(cp.p4.y)"
stroke="#93c5fd" stroke-width="1" stroke-dasharray="6 3" opacity="0.6" />
<line :x1="cx(cp.p4.x)" :y1="cy(cp.p4.y)" :x2="cx(cp.p5.x)" :y2="cy(cp.p5.y)"
stroke="#fca5a5" stroke-width="1" stroke-dasharray="6 3" opacity="0.6" />
<line :x1="cx(cp.p6.x)" :y1="cy(cp.p6.y)" :x2="cx(cp.p7.x)" :y2="cy(cp.p7.y)"
stroke="#fca5a5" stroke-width="1" stroke-dasharray="6 3" opacity="0.6" />
<!-- Prix moyen avec abonnement (abo/v + marginal) dashed green -->
<polyline v-if="citizenAbo > 0" :points="avgPriceWithAboPath" fill="none"
stroke="#059669" stroke-width="2" stroke-linecap="round" stroke-dasharray="6 4" opacity="0.8" />
<!-- Bezier curve: population (blue gradient, thick) -->
<path :d="tier1Path" fill="none"
: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 ? (isDark ? '#475569' : '#cbd5e1') : '#ea580c'"
:stroke-width="showHouseholds ? 2 : 2.5" stroke-linecap="round" />
<!-- Household dots on curves -->
<g v-if="showHouseholds && householdVolumes.length">
<circle v-for="(hh, i) in householdDots" :key="'hd'+i"
:cx="cx(hh.volume)" :cy="cy(hh.price)"
r="3.5"
:fill="hh.volume <= bp.vinf ? '#2563eb' : '#ea580c'"
:opacity="0.65"
:stroke="t.plotBg" stroke-width="0.8"
/>
</g>
<!-- Inflection reference lines -->
<line :x1="cx(bp.vinf)" :y1="margin.top + plotH" :x2="cx(bp.vinf)" :y2="cy(localP0)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4 4" />
<line :x1="cx(bp.vinf)" :y1="cy(localP0)" :x2="margin.left + plotW" :y2="cy(localP0)"
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="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>
<!-- Median marginal price bar (draggable) -->
<line :x1="margin.left" :y1="cy(medianPrice)" :x2="margin.left + plotW" :y2="cy(medianPrice)"
stroke="#059669" stroke-width="1.5" stroke-dasharray="8 4" opacity="0.7" />
<rect :x="margin.left + plotW - 178" :y="cy(medianPrice) - 16" width="170" height="18" rx="9"
fill="#059669" fill-opacity="0.12" stroke="#059669" stroke-width="0.5" />
<text :x="margin.left + plotW - 93" :y="cy(medianPrice) - 4" text-anchor="middle"
font-size="10" fill="#059669" font-weight="600">
Prix median : {{ medianPrice.toFixed(2) }} /
</text>
</g>
<!-- Median bar drag handle (outside clip so always visible) -->
<circle
:cx="margin.left + 14" :cy="cy(medianPrice)"
:r="dragging === 'medianBar' ? 10 : 7"
fill="#059669" :stroke="t.plotBg" :stroke-width="dragging === 'medianBar' ? 3 : 2"
class="drag-handle"
@mousedown.prevent="startDrag('medianBar')"
@touchstart.prevent="startDrag('medianBar')"
/>
<text :x="margin.left + 28" :y="cy(medianPrice) + 4"
font-size="9" fill="#059669" font-weight="600" opacity="0.7">
</text>
<!-- Draggable control points -->
<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="t.plotBg" :stroke-width="dragging === key ? 3 : 2"
class="drag-handle"
@mousedown.prevent="startDrag(key)"
@touchstart.prevent="startDrag(key)"
/>
<text v-for="(pt, key) in dragPoints" :key="'l-'+key"
:x="cx(pt.x) + 12" :y="cy(pt.y) - 12"
font-size="10" :fill="ptColors[key]" font-weight="600" opacity="0.8">
{{ ptLabels[key] }}
</text>
<!-- 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="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="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="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="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="t.text">Prix moyen avec abo.</text>
</g>
</g>
</svg>
</div>
</div>
</div> <!-- end main-layout-chart -->
<!-- RIGHT SIDEBAR: Auth + Vote -->
<div class="main-layout-sidebar">
<!-- Auth: Code electeur -->
<div class="card sidebar-card">
<div v-if="isCitizenAuth" class="alert alert-success" style="margin-bottom: 0;">
Authentifie. Ajustez la courbe et votez.
</div>
<template v-else>
<div class="sidebar-auth-block">
<h4>Code electeur</h4>
<p class="sidebar-muted">par carte electorale</p>
<div v-if="authError" class="alert alert-error" style="margin-bottom: 0.5rem; font-size: 0.8rem;">{{ authError }}</div>
<form @submit.prevent="authenticate" style="display: flex; gap: 0.4rem;">
<input v-model="authCode" type="text" maxlength="8" placeholder="Code electeur"
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="{ 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>
<div class="sidebar-auth-block" style="opacity: 0.5; border-top: 1px solid var(--color-border); padding-top: 0.75rem; margin-top: 0.75rem;">
<h4>Toile fiduciaire decentralisee</h4>
<p class="sidebar-muted">Authentification avec identite numerique Duniter</p>
<button class="btn btn-secondary" disabled style="font-size: 0.78rem; padding: 0.3rem 0.6rem;">Bientot disponible</button>
</div>
</template>
</div>
<!-- Vote -->
<div class="card sidebar-card">
<h4 style="margin-bottom: 0.25rem;">Mon vote</h4>
<p style="font-size: 0.78rem; color: var(--color-text-muted); margin-bottom: 0.6rem;">
Ajustez la courbe, puis sauvegardez.
</p>
<button class="btn btn-primary" @click="submitVote" style="width: 100%; font-size: 0.9rem;"
:disabled="!isCitizenAuth || submitting || isVoteClosed">
<span v-if="submitting" class="spinner" style="width: 0.9rem; height: 0.9rem;"></span>
Sauvegarder mon vote
</button>
<p v-if="lastVoteDate" style="font-size: 0.72rem; color: var(--color-text-muted); margin-top: 0.4rem; text-align: center;">
Dernier vote enregistre le {{ lastVoteDate }}
</p>
<p v-if="!isCitizenAuth" style="font-size: 0.72rem; color: var(--color-text-muted); margin-top: 0.4rem; text-align: center;">
Authentifiez-vous pour voter.
</p>
<p v-else-if="isVoteClosed" style="font-size: 0.72rem; color: #dc2626; margin-top: 0.4rem; text-align: center;">
Le vote est clos.
</p>
<div v-if="voteSuccess" class="alert alert-success" style="margin-top: 0.5rem; font-size: 0.8rem;">
Vote enregistre !
</div>
</div>
<!-- Countdown (if applicable) -->
<div v-if="commune.vote_deadline" class="card sidebar-card">
<h4 style="margin-bottom: 0.25rem;">Echeance</h4>
<div v-if="isVoteClosed" style="font-size: 0.9rem; color: #dc2626; font-weight: 600;">
Vote clos.
</div>
<div v-else class="countdown-compact">
<span><strong>{{ countdown.days }}</strong>j</span>
<span><strong>{{ countdown.hours }}</strong>h</span>
<span><strong>{{ countdown.minutes }}</strong>m</span>
<span><strong>{{ countdown.seconds }}</strong>s</span>
</div>
</div>
</div>
</div> <!-- end main-layout -->
<!--
SITUATION DE DEPART
-->
<h2 class="section-title">Situation de depart</h2>
<!-- Histogramme des consommations -->
<div v-if="distBuckets.length" class="chart-card" style="margin-bottom: 1.5rem;">
<div class="chart-header">
<div>
<h3 class="chart-title">Repartition des consommations</h3>
<p class="chart-subtitle">Nombre de foyers par tranche de consommation (/an)</p>
</div>
</div>
<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="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="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>
<!-- Bars -->
<g v-for="(bk, i) in distBuckets" :key="'hb'+i">
<rect
:x="histCx(bk.low) + 1"
:y="histCy(bk.count)"
:width="Math.max(0, histCx(bk.high) - histCx(bk.low) - 2)"
:height="Math.max(0, histCy(0) - histCy(bk.count))"
:fill="bk.high <= bp.vinf ? '#2563eb' : '#ea580c'"
:opacity="bk.high <= bp.vinf ? 0.7 : 0.7"
rx="2"
/>
<!-- Count label above bar -->
<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="t.text" font-weight="600">
{{ bk.count }}
</text>
</g>
<!-- Vinf dashed line -->
<line :x1="histCx(bp.vinf)" :y1="histMargin.top" :x2="histCx(bp.vinf)" :y2="histMargin.top + histPlotH"
stroke="#8b5cf6" stroke-width="1.5" stroke-dasharray="5 3" />
<text :x="histCx(bp.vinf)" :y="histMargin.top - 4" text-anchor="middle" font-size="10" fill="#8b5cf6" font-weight="600">
Palier : {{ bp.vinf.toFixed(0) }}
</text>
<!-- X axis labels -->
<g>
<template v-for="(bk, i) in distBuckets" :key="'hx'+i">
<text v-if="i % 2 === 0 || distBuckets.length <= 12"
:x="histCx(bk.low)" :y="histMargin.top + histPlotH + 16" text-anchor="middle" class="axis-label-sm">
{{ bk.low }}
</text>
</template>
</g>
<!-- 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="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="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="t.text">Consommations exceptionnelles</text>
</g>
<!-- Axis titles -->
<text :x="histMargin.left + histPlotW / 2" :y="histMargin.top + histPlotH + 36" text-anchor="middle" class="axis-title">
Consommation ()
</text>
<text :x="histMargin.left - 28" :y="histMargin.top + histPlotH / 2" text-anchor="middle"
class="axis-title" transform-origin="center" :transform="`rotate(-90, ${histMargin.left - 28}, ${histMargin.top + histPlotH / 2})`">
Foyers
</text>
</svg>
</div>
</div>
<!--
INDICATEURS CLES
-->
<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>
<span class="key-metric-tag">contrainte</span>
</div>
<div class="key-metric key-metric-input">
<span class="key-metric-value">{{ bp.vinf.toFixed(0) }} </span>
<span class="key-metric-label">Palier exceptionnel</span>
<span class="key-metric-tag">contrainte</span>
</div>
<div class="key-metric key-metric-result">
<span class="key-metric-value">{{ localP0.toFixed(2) }} /</span>
<span class="key-metric-label">Prix au palier</span>
<span class="key-metric-tag key-metric-tag-calc">calcule</span>
</div>
<div class="key-metric key-metric-result">
<span class="key-metric-value">{{ medianPrice.toFixed(2) }} /</span>
<span class="key-metric-label">Prix median</span>
<span class="key-metric-tag key-metric-tag-calc">calcule</span>
</div>
<div class="key-metric key-metric-highlight">
<span class="key-metric-value">{{ citizenPriceM3 }}</span>
<span class="key-metric-label">Mon prix au </span>
</div>
</div>
<!--
PARAMETRES COURBE + TABLE IMPACTS
-->
<div class="params-impacts-layout" style="margin-bottom: 1.5rem;">
<div class="card">
<h4 style="margin-bottom: 0.5rem;">Parametres de la courbe</h4>
<div class="param-grid">
<div class="param-row">
<span class="param-label">v<sub>inf</sub></span>
<span class="param-val">{{ bp.vinf.toFixed(0) }} </span>
</div>
<div class="param-row">
<span class="param-label">a</span>
<span class="param-val">{{ bp.a.toFixed(3) }}</span>
</div>
<div class="param-row">
<span class="param-label">b</span>
<span class="param-val">{{ bp.b.toFixed(3) }}</span>
</div>
<div class="param-row">
<span class="param-label">c</span>
<span class="param-val">{{ bp.c.toFixed(3) }}</span>
</div>
<div class="param-row">
<span class="param-label">d</span>
<span class="param-val">{{ bp.d.toFixed(3) }}</span>
</div>
<div class="param-row">
<span class="param-label">e</span>
<span class="param-val">{{ bp.e.toFixed(3) }}</span>
</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: 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>
</div>
<div class="vote-result-row">
<span>un prix median de</span>
<strong>{{ medianPrice.toFixed(2) }} /</strong>
</div>
</div>
</div>
<div class="card">
<h4 style="margin-bottom: 0.5rem;">Impact par volume</h4>
<table class="table table-sm">
<thead>
<tr>
<th>Vol.</th>
<th>Actuel</th>
<th>{{ differentiatedTariff ? 'RP/PRO' : 'Nouveau' }}</th>
<th v-if="differentiatedTariff">RS</th>
</tr>
</thead>
<tbody>
<tr v-for="imp in impacts" :key="imp.volume">
<td>{{ imp.volume }} </td>
<td>{{ imp.old_price.toFixed(0) }} </td>
<td :class="imp.new_price_rp > imp.old_price ? 'text-up' : 'text-down'">
{{ imp.new_price_rp.toFixed(0) }}
</td>
<td v-if="differentiatedTariff" :class="imp.new_price_rs > imp.old_price ? 'text-up' : 'text-down'">
{{ imp.new_price_rs.toFixed(0) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!--
7. TARIFICATION ACTUELLE (baseline lineaire) Axe X inverse
-->
<div class="chart-card" style="margin-bottom: 1.5rem;">
<h3 class="chart-title">Tarification actuelle (modele lineaire)</h3>
<p class="chart-subtitle" style="margin-bottom: 1rem;">
Situation tarifaire en vigueur : prix uniforme de {{ curveData.p0_linear?.toFixed(2) }} / + abonnement {{ abop }} <template v-if="differentiatedTariff"> (RP/PRO) / {{ abos }} (RS)</template>.
</p>
<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: 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="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="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="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"
:x="margin2.left + plotW2 + 4" :y="cy2bill(b) + 3" text-anchor="start" class="axis-label-sm">{{ b }}</text>
<!-- Household counts per 30 -->
<g v-if="distBuckets30.length">
<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="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="t.textMuted">foy.</text>
</g>
</g>
<defs>
<clipPath id="baseline-clip-1">
<rect :x="margin2.left" :y="margin2.top" :width="plotW2" :height="plotH2" />
</clipPath>
</defs>
<g clip-path="url(#baseline-clip-1)">
<polyline :points="baselineBillRP" fill="none"
: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 ? (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="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="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="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="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: 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="t.plotBg" rx="3" />
<defs>
<clipPath id="baseline-clip-2">
<rect :x="margin2.left" :y="margin2.top" :width="plotW2" :height="plotH2" />
</clipPath>
</defs>
<g>
<line v-for="v in gridVols2" :key="'bg2v'+v"
:x1="cx2(v)" :y1="margin2.top" :x2="cx2(v)" :y2="margin2.top + plotH2"
: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="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"
:x="margin2.left + plotW2 + 4" :y="cy2price(p) + 3" text-anchor="start" class="axis-label-sm">{{ p }}</text>
<!-- Household counts per 30 -->
<g v-if="distBuckets30.length">
<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="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="t.textMuted">foy.</text>
</g>
</g>
<g clip-path="url(#baseline-clip-2)">
<polyline :points="baselinePriceRP" fill="none"
: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 ? (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="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="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="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="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="t.text">RS</text>
</g>
</svg>
</div>
</div>
<!-- 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: 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="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="isDark ? '#60a5fa' : '#2563eb'" stop-opacity="0.35" />
<stop offset="100%" :stop-color="isDark ? '#60a5fa' : '#2563eb'" stop-opacity="0.12" />
</linearGradient>
</defs>
<!-- Grid -->
<g>
<line v-for="v in margGridVols" :key="'mgv'+v"
:x1="margCx(v)" :y1="margMargin.top" :x2="margCx(v)" :y2="margMargin.top + margPlotH"
: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="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>
<!-- Price labels (right) -->
<text v-for="p in margGridPrices" :key="'mglp'+p"
:x="margMargin.left + margPlotW + 4" :y="margCyPrice(p) + 3" text-anchor="start" class="axis-label-sm">
{{ p.toFixed(p < 1 ? 1 : 0) }}
</text>
<!-- 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="t.textMuted" font-weight="500">{{ c }}</text>
</g>
<!-- Household histogram bars (behind the curve) -->
<g clip-path="url(#marg-clip)">
<g v-for="(bk, i) in distBuckets30" :key="'mb'+i">
<rect v-if="bk.count > 0"
:x="Math.min(margCx(bk.low), margCx(bk.high))"
:y="margCyCount(bk.count)"
:width="Math.abs(margCx(bk.high) - margCx(bk.low))"
:height="Math.max(0, margCyCount(0) - margCyCount(bk.count))"
fill="url(#bar-grad)" rx="1"
/>
<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="t.textCount" font-weight="600">
{{ bk.count }}
</text>
</g>
<!-- Price line (marginal) -->
<polyline :points="margPriceLine" fill="none"
:stroke="showHouseholds ? (isDark ? '#475569' : '#cbd5e1') : (isDark ? '#93c5fd' : '#1e40af')"
:stroke-width="showHouseholds ? 1.5 : 2.5" stroke-linecap="round" />
<!-- Household dots on marginal price chart -->
<g v-if="showHouseholds && householdVolumes.length">
<circle v-for="(hh, i) in householdDotsMarg" :key="'hm'+i"
:cx="margCx(hh.volume)" :cy="margCyPrice(hh.price)"
r="3"
:fill="isDark ? '#93c5fd' : '#1e40af'" opacity="0.5"
:stroke="t.plotBg" stroke-width="0.6"
/>
</g>
<!-- p0 horizontal dashed line -->
<line :x1="margMargin.left" :y1="margCyPrice(curveData.p0_linear)" :x2="margMargin.left + margPlotW" :y2="margCyPrice(curveData.p0_linear)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4 3" />
</g>
<!-- p0 label -->
<text :x="margMargin.left + margPlotW + 4" :y="margCyPrice(curveData.p0_linear) - 5" text-anchor="start"
font-size="9" :fill="t.textCount" font-weight="600">
{{ curveData.p0_linear?.toFixed(2) }}/
</text>
<!-- Axis titles -->
<text :x="Wmarg / 2" :y="margMargin.top + margPlotH + 40" text-anchor="middle" class="axis-title">
Consommation () reduire
</text>
<!-- Y-axis title left: Foyers -->
<text :x="8" :y="margMargin.top + margPlotH / 2" text-anchor="middle"
class="axis-title" :transform="`rotate(-90, 8, ${margMargin.top + margPlotH / 2})`">
Nombre de foyers
</text>
<!-- Y-axis title right: / -->
<text :x="Wmarg - 4" :y="margMargin.top - 6" text-anchor="end" class="axis-title">
/
</text>
<!-- Legend -->
<g :transform="`translate(${margMargin.left + 8}, ${margMargin.top + 6})`">
<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>
</div>
</div>
<!--
CONTENU CMS
-->
<div v-if="contentPages.length" style="margin-bottom: 1.5rem;">
<div v-for="page in contentPages" :key="page.slug" class="card" style="margin-bottom: 1rem;">
<h3 style="margin-bottom: 0.5rem;">{{ page.title }}</h3>
<div class="cms-body" v-html="renderMarkdown(page.body_markdown)"></div>
</div>
</div>
</template>
</div>
<div v-else-if="loadError" class="alert alert-error">{{ loadError }}</div>
<div v-else style="text-align: center; padding: 3rem;">
<div class="spinner" style="margin: 0 auto;"></div>
</div>
</template>
<script setup lang="ts">
import {
computeP0, computeImpacts, computeIntegrals, generateCurve,
paramsToControlPoints,
type HouseholdData, type ImpactRow, type ControlPoints,
} from '~/utils/bezier-math'
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)
const params = ref<any>(null)
const curveData = ref<any>(null)
const loading = ref(true)
const loadError = ref('')
const contentPages = ref<any[]>([])
// Bezier params (citizen-adjustable)
const bp = reactive({ vinf: 400, a: 0.5, b: 0.5, c: 0.5, d: 0.5, e: 0.5 })
const localP0 = ref(0)
const impacts = ref<any[]>([])
const households = ref<HouseholdData[]>([])
// Tariff fixed params
const vmax = ref(2100)
const pmax = ref(20)
const recettes = ref(75000)
const abop = ref(100)
const abos = ref(100)
const differentiatedTariff = ref(false)
// Auth
const authCode = ref('')
const authError = ref('')
const authLoading = ref(false)
const isCitizenAuth = computed(() => authStore.isCitizen && authStore.communeSlug === slug)
const submitting = ref(false)
const voteSuccess = ref(false)
const lastVoteDate = ref<string | null>(null)
const citizenVolume = ref<number | null>(null)
const isDev = import.meta.dev
// Citizen's own price per m³ (from their volume, looked up on the Bézier curve)
const citizenPriceM3 = computed(() => {
if (citizenVolume.value === null) return '—'
const vol = citizenVolume.value
if (vol <= 0) return '0.00 €/m³'
// Find price on current Bézier curve at citizen's volume
const dot = householdDots.value.find(d => Math.abs(d.volume - vol) < 0.1)
if (dot) return dot.price.toFixed(2) + ' €/m³'
// Fallback: generate curve and interpolate
const curve = generateCurve(bp.vinf, vmax.value, pmax.value, localP0.value, bp.a, bp.b, bp.c, bp.d, bp.e, 200)
for (let j = 1; j < curve.curveVolumes.length; j++) {
if (curve.curveVolumes[j - 1]! <= vol && curve.curveVolumes[j]! >= vol) {
const dv = curve.curveVolumes[j]! - curve.curveVolumes[j - 1]!
if (Math.abs(dv) < 1e-10) return curve.curvePricesM3[j]!.toFixed(2) + ' €/m³'
const t = (vol - curve.curveVolumes[j - 1]!) / dv
const price = curve.curvePricesM3[j - 1]! + t * (curve.curvePricesM3[j]! - curve.curvePricesM3[j - 1]!)
return price.toFixed(2) + ' €/m³'
}
}
return '—'
})
// ── Countdown ──
const countdown = reactive({ days: 0, hours: 0, minutes: 0, seconds: 0 })
const isVoteClosed = ref(false)
let countdownInterval: ReturnType<typeof setInterval> | null = null
function updateCountdown() {
if (!commune.value?.vote_deadline) return
const now = Date.now()
const deadline = new Date(commune.value.vote_deadline).getTime()
const diff = deadline - now
if (diff <= 0) {
isVoteClosed.value = true
countdown.days = 0; countdown.hours = 0; countdown.minutes = 0; countdown.seconds = 0
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null }
return
}
isVoteClosed.value = false
countdown.days = Math.floor(diff / 86400000)
countdown.hours = Math.floor((diff % 86400000) / 3600000)
countdown.minutes = Math.floor((diff % 3600000) / 60000)
countdown.seconds = Math.floor((diff % 60000) / 1000)
}
onUnmounted(() => { if (countdownInterval) clearInterval(countdownInterval) })
// ── Chart 1: Interactive Bezier with zoom — X AXIS INVERTED ──
const W = 700
const H = 400
const margin = { top: 24, right: 52, bottom: 56, left: 16 }
const plotW = W - margin.left - margin.right
const plotH = H - margin.top - margin.bottom
const zoomVolMin = ref(0)
const zoomVolMax = ref(2100)
const zoomPriceMin = ref(0)
const zoomPriceMax = ref(20)
const showOutliers = ref(false)
const showHouseholds = ref(false)
const outlierVotes = ref<any[]>([])
const citizenAbo = ref(0)
const householdVolumes = ref<Array<{ volume_m3: number; status: string }>>([])
// ── Distribution histogram data ──
const distBuckets = ref<Array<{ low: number; high: number; count: number }>>([])
// Histogram chart dimensions
const histW = 700
const histH = 240
const histMargin = { top: 24, right: 16, bottom: 48, left: 42 }
const histPlotW = histW - histMargin.left - histMargin.right
const histPlotH = histH - histMargin.top - histMargin.bottom
function histCx(v: number): number {
const last = distBuckets.value[distBuckets.value.length - 1]
const maxVol = last ? last.high : 1
return histMargin.left + (v / maxVol) * histPlotW
}
const histMaxCount = computed(() => {
if (!distBuckets.value.length) return 10
return Math.ceil(Math.max(...distBuckets.value.map(b => b.count)) * 1.15)
})
function histCy(c: number): number {
return histMargin.top + histPlotH - (c / histMaxCount.value) * histPlotH
}
const histGridY = computed(() => {
const step = Math.max(1, Math.ceil(histMaxCount.value / 5 / 10) * 10)
const arr: number[] = []
for (let y = step; y < histMaxCount.value; y += step) arr.push(y)
return arr
})
// INVERTED X: high volumes on LEFT, 0 on RIGHT
function cx(v: number) {
return margin.left + plotW - ((v - zoomVolMin.value) / (zoomVolMax.value - zoomVolMin.value)) * plotW
}
function cy(p: number) {
return margin.top + plotH - ((p - zoomPriceMin.value) / (zoomPriceMax.value - zoomPriceMin.value)) * plotH
}
// Inverse mapping for drag: screen X → data volume (inverted)
function fromX(sx: number) {
return zoomVolMin.value + ((margin.left + plotW - sx) / plotW) * (zoomVolMax.value - zoomVolMin.value)
}
function fromY(sy: number) {
return zoomPriceMin.value + ((margin.top + plotH - sy) / plotH) * (zoomPriceMax.value - zoomPriceMin.value)
}
function zoomIn() {
const dv = (zoomVolMax.value - zoomVolMin.value) * 0.15
const dp = (zoomPriceMax.value - zoomPriceMin.value) * 0.15
zoomVolMin.value += dv; zoomVolMax.value -= dv
zoomPriceMin.value += dp; zoomPriceMax.value -= dp
}
function zoomOut() {
const dv = (zoomVolMax.value - zoomVolMin.value) * 0.2
const dp = (zoomPriceMax.value - zoomPriceMin.value) * 0.2
zoomVolMin.value = Math.max(0, zoomVolMin.value - dv)
zoomVolMax.value = Math.min(vmax.value, zoomVolMax.value + dv)
zoomPriceMin.value = Math.max(0, zoomPriceMin.value - dp)
zoomPriceMax.value = Math.min(pmax.value, zoomPriceMax.value + dp)
}
function zoomReset() {
zoomVolMin.value = 0; zoomVolMax.value = vmax.value
zoomPriceMin.value = 0; zoomPriceMax.value = pmax.value
}
function zoomPreset(tier: string) {
if (tier === 'tier1') {
zoomVolMin.value = 0
zoomVolMax.value = Math.min(bp.vinf * 1.3, vmax.value)
zoomPriceMin.value = 0
zoomPriceMax.value = Math.min(Math.max(localP0.value * 1.5, 2), pmax.value)
} else {
zoomVolMin.value = Math.max(0, bp.vinf * 0.8)
zoomVolMax.value = vmax.value
zoomPriceMin.value = Math.max(0, localP0.value * 0.7)
zoomPriceMax.value = pmax.value
}
}
function onWheel(e: WheelEvent) { e.deltaY < 0 ? zoomIn() : zoomOut() }
const gridVols = computed(() => {
const range = zoomVolMax.value - zoomVolMin.value
const step = Math.max(10, Math.ceil(range / 7 / (range > 500 ? 100 : 10)) * (range > 500 ? 100 : 10))
const arr: number[] = []
const start = Math.ceil(zoomVolMin.value / step) * step
for (let v = start; v < zoomVolMax.value; v += step) arr.push(v)
return arr
})
const gridPrices = computed(() => {
const range = zoomPriceMax.value - zoomPriceMin.value
const step = range > 5 ? Math.ceil(range / 5) : range > 1 ? 1 : 0.5
const arr: number[] = []
const start = Math.ceil(zoomPriceMin.value / step) * step
for (let p = start; p <= zoomPriceMax.value; p += step) arr.push(p)
return arr
})
const cp = computed<ControlPoints>(() =>
paramsToControlPoints(bp.vinf, vmax.value, pmax.value, localP0.value, bp.a, bp.b, bp.c, bp.d, bp.e)
)
const dragPoints = computed(() => ({
p2: cp.value.p2, p3: cp.value.p3, p4: cp.value.p4, p5: cp.value.p5, p6: cp.value.p6,
}))
const ptColors: Record<string, string> = {
p2: '#3b82f6', p3: '#3b82f6', p4: '#8b5cf6', p5: '#ea580c', p6: '#ea580c',
}
const ptLabels: Record<string, string> = {
p2: 'a', p3: 'b', p4: 'vinf', p5: 'c', p6: 'd,e',
}
const tier1Path = computed(() => {
const c = cp.value
return `M ${cx(c.p1.x)} ${cy(c.p1.y)} C ${cx(c.p2.x)} ${cy(c.p2.y)}, ${cx(c.p3.x)} ${cy(c.p3.y)}, ${cx(c.p4.x)} ${cy(c.p4.y)}`
})
const tier2Path = computed(() => {
const c = cp.value
return `M ${cx(c.p4.x)} ${cy(c.p4.y)} C ${cx(c.p5.x)} ${cy(c.p5.y)}, ${cx(c.p6.x)} ${cy(c.p6.y)}, ${cx(c.p7.x)} ${cy(c.p7.y)}`
})
// Prix moyen avec abonnement : (abo + integral) / v
// Uses generateCurve to get the Bézier price curve, then adds abo/v
const avgPriceWithAboPath = computed(() => {
if (citizenAbo.value <= 0) return ''
const curve = generateCurve(bp.vinf, vmax.value, pmax.value, localP0.value, bp.a, bp.b, bp.c, bp.d, bp.e, 150)
const pts: string[] = []
for (let i = 1; i < curve.curveVolumes.length; i++) {
const v = curve.curveVolumes[i]
if (v < 1) continue
// Approximate bill at volume v: we use trapezoidal integration of marginal price
// Simpler: bill ≈ average_marginal_price * v, and average_marginal_price ≈ curvePrice at v
// More accurate: use the integral formulas. But for display, we use:
// prix_moyen = abo/v + prix_marginal(v) * correction
// Actually, the correct formula for a Bézier tariff:
// bill(v) = integral_0^v of marginal_price(u) du
// prix_moyen(v) = abo/v + bill(v)/v
// For simplicity and reactivity, we approximate bill(v) using trapezoidal sum
let bill = 0
for (let j = 1; j <= i; j++) {
const dv = curve.curveVolumes[j] - curve.curveVolumes[j - 1]
const avgP = (curve.curvePricesM3[j] + curve.curvePricesM3[j - 1]) / 2
bill += avgP * dv
}
const avgPrice = citizenAbo.value / v + bill / v
if (avgPrice >= 0 && avgPrice <= pmax.value * 2) {
pts.push(`${cx(v)},${cy(avgPrice)}`)
}
}
return pts.join(' ')
})
// Household dots: compute Bézier price at each household's volume
const householdDots = computed(() => {
if (!householdVolumes.value.length) return []
const curve = generateCurve(bp.vinf, vmax.value, pmax.value, localP0.value, bp.a, bp.b, bp.c, bp.d, bp.e, 300)
const vols = curve.curveVolumes
const prices = curve.curvePricesM3
return householdVolumes.value.map(hh => {
const v = hh.volume_m3
// Find the price by linear interpolation in the generated curve
let price = 0
for (let j = 1; j < vols.length; j++) {
if ((vols[j - 1]! <= v && vols[j]! >= v) || (vols[j]! <= v && vols[j - 1]! >= v)) {
const dv = vols[j]! - vols[j - 1]!
if (Math.abs(dv) < 1e-10) { price = prices[j]!; break }
const t = (v - vols[j - 1]!) / dv
price = prices[j - 1]! + t * (prices[j]! - prices[j - 1]!)
break
}
}
// If volume beyond curve range, use last price
if (v >= vols[vols.length - 1]!) price = prices[prices.length - 1]!
return { volume: v, price, status: hh.status }
})
})
function outlierPath(vote: any, tier: number): string {
const p0 = vote.computed_p0 || localP0.value
const oc = paramsToControlPoints(vote.vinf, vmax.value, pmax.value, p0, vote.a, vote.b, vote.c, vote.d, vote.e)
if (tier === 1) {
return `M ${cx(oc.p1.x)} ${cy(oc.p1.y)} C ${cx(oc.p2.x)} ${cy(oc.p2.y)}, ${cx(oc.p3.x)} ${cy(oc.p3.y)}, ${cx(oc.p4.x)} ${cy(oc.p4.y)}`
}
return `M ${cx(oc.p4.x)} ${cy(oc.p4.y)} C ${cx(oc.p5.x)} ${cy(oc.p5.y)}, ${cx(oc.p6.x)} ${cy(oc.p6.y)}, ${cx(oc.p7.x)} ${cy(oc.p7.y)}`
}
// ── Drag handling ──
const svgRef = ref<SVGSVGElement | null>(null)
const dragging = ref<string | null>(null)
function getSvgPt(event: MouseEvent | Touch) {
if (!svgRef.value) return { x: 0, y: 0 }
const rect = svgRef.value.getBoundingClientRect()
return {
x: (event.clientX - rect.left) * (W / rect.width),
y: (event.clientY - rect.top) * (H / rect.height),
}
}
function startDrag(key: string) { dragging.value = key }
function onMouseMove(e: MouseEvent) { if (dragging.value) handleDrag(getSvgPt(e)) }
function onTouchMove(e: TouchEvent) { if (dragging.value && e.touches[0]) handleDrag(getSvgPt(e.touches[0])) }
function onMouseUp() {
if (dragging.value) {
dragging.value = null
recompute()
}
}
function handleDrag(pt: { x: number; y: number }) {
const v = Math.max(0, Math.min(vmax.value, fromX(pt.x)))
const p = Math.max(0, Math.min(pmax.value, fromY(pt.y)))
switch (dragging.value) {
case 'p2':
bp.a = localP0.value > 0 ? Math.max(0, Math.min(1, p / localP0.value)) : 0.5
break
case 'p3':
bp.b = bp.vinf > 0 ? Math.max(0, Math.min(1, v / bp.vinf)) : 0.5
break
case 'p4':
bp.vinf = Math.max(1, Math.min(vmax.value - 1, v))
break
case 'p5': {
const wmax = vmax.value - bp.vinf
bp.c = wmax > 0 ? Math.max(0, Math.min(1, (v - bp.vinf) / wmax)) : 0.5
break
}
case 'p6': {
const wmax = vmax.value - bp.vinf
const qmax = pmax.value - localP0.value
bp.e = qmax > 0 ? Math.max(0, Math.min(1, (p - localP0.value) / qmax)) : 0.5
if (wmax > 0 && Math.abs(1 - bp.c) > 1e-10) {
const ratio = (v - bp.vinf) / wmax
bp.d = Math.max(0, Math.min(1, (1 - ratio) / (1 - bp.c)))
}
break
}
case 'medianBar': {
// Drag the median price bar → scale p0 and back-compute recettes
const newPrice = Math.max(0.01, Math.min(pmax.value * 0.8, p))
const oldMedian = medianPrice.value
if (oldMedian > 0.001 && hhData.value.length > 0) {
const scale = newPrice / oldMedian
const newP0 = localP0.value * scale
// Back-compute recettes from new p0
let totalAbo = 0, totalAlpha = 0, totalBeta = 0
for (const hh of hhData.value) {
totalAbo += hh.status === 'RS' ? abos.value : abop.value
const { alpha1, alpha2, beta2 } = computeIntegrals(
Math.max(hh.volume_m3, 1e-5), bp.vinf, vmax.value, pmax.value,
bp.a, bp.b, bp.c, bp.d, bp.e,
)
totalAlpha += alpha1 + alpha2
totalBeta += beta2
}
recettes.value = Math.max(0, newP0 * totalAlpha + totalAbo + totalBeta)
localP0.value = newP0
}
break
}
}
}
// ── Client-side household data for local computation ──
const hhData = computed<HouseholdData[]>(() =>
householdVolumes.value.map(h => ({ volume_m3: h.volume_m3, status: h.status }))
)
// Median volume and median marginal price
const medianVolume = computed(() => {
if (!householdVolumes.value.length) return 60
const sorted = [...householdVolumes.value].map(h => h.volume_m3).sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!
})
const medianPrice = computed(() => {
if (!householdDots.value.length) return localP0.value * 0.3
const sorted = [...householdDots.value].map(d => d.price).sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!
})
// Recompute p0 and impacts client-side (instant, no server round-trip)
function recompute() {
if (hhData.value.length > 0) {
localP0.value = computeP0(
hhData.value, recettes.value, abop.value, abos.value,
bp.vinf, vmax.value, pmax.value,
bp.a, bp.b, bp.c, bp.d, bp.e,
)
const result = computeImpacts(
hhData.value, recettes.value, abop.value, abos.value,
bp.vinf, vmax.value, pmax.value,
bp.a, bp.b, bp.c, bp.d, bp.e,
)
impacts.value = result.impacts.map(imp => ({
volume: imp.volume,
old_price: imp.oldPrice,
new_price_rp: imp.newPriceRP,
new_price_rs: imp.newPriceRS,
}))
} else {
// Fallback to server if no household data yet
serverComputeFallback()
}
}
let serverTimeout: ReturnType<typeof setTimeout> | null = null
function serverComputeFallback() {
if (serverTimeout) clearTimeout(serverTimeout)
serverTimeout = setTimeout(async () => {
try {
const result = await api.post<any>('/tariff/compute', {
commune_slug: slug, vinf: bp.vinf,
a: bp.a, b: bp.b, c: bp.c, d: bp.d, e: bp.e,
})
localP0.value = result.p0
impacts.value = result.impacts.map((imp: any) => ({
volume: imp.volume,
old_price: imp.old_price,
new_price_rp: imp.new_price_rp,
new_price_rs: imp.new_price_rs,
}))
} catch {}
}, 150)
}
// ── Chart 2: Baseline linear model — X AXIS INVERTED ──
const W2 = 340
const H2 = 230
const margin2 = { top: 10, right: 42, bottom: 52, left: 12 }
const plotW2 = W2 - margin2.left - margin2.right
const plotH2 = H2 - margin2.top - margin2.bottom
// Baseline chart range: tight focus on first tier for detail on small consumptions
const baselineVolMax = computed(() => {
const target = Math.max(bp.vinf * 0.8, 300)
return Math.min(Math.ceil(target / 50) * 50, vmax.value)
})
// INVERTED: high volumes left, 0 right
function cx2(v: number) { return margin2.left + plotW2 - (v / baselineVolMax.value) * plotW2 }
const maxBill = computed(() => {
if (!curveData.value?.baseline_bills_rp?.length || !curveData.value?.baseline_volumes?.length) return 500
// Only consider bills within the focused baseline range
const vols = curveData.value.baseline_volumes
const bills = curveData.value.baseline_bills_rp
let mx = 0
for (let i = 0; i < vols.length; i++) {
if (vols[i] <= baselineVolMax.value && bills[i] > mx) mx = bills[i]
}
return Math.ceil((mx || 500) * 1.15 / 100) * 100
})
function cy2bill(b: number) { return margin2.top + plotH2 - (b / maxBill.value) * plotH2 }
// Focused price range for baseline charts
const baselinePriceMax = computed(() => {
if (!curveData.value?.baseline_price_m3_rp?.length || !curveData.value?.baseline_volumes?.length) return pmax.value
const vols = curveData.value.baseline_volumes
const prices = curveData.value.baseline_price_m3_rp
let mx = 0
for (let i = 0; i < vols.length; i++) {
if (vols[i] <= baselineVolMax.value && vols[i] > 0.5 && prices[i] > mx) mx = prices[i]
}
return mx > 0 ? Math.ceil(mx * 1.3) : pmax.value
})
function cy2price(p: number) { return margin2.top + plotH2 - (p / baselinePriceMax.value) * plotH2 }
const gridVols2 = computed(() => {
const mx = baselineVolMax.value
const step = mx > 500 ? Math.ceil(mx / 5 / 100) * 100 : Math.ceil(mx / 5 / 50) * 50
const arr: number[] = []
for (let v = step; v < mx; v += step) arr.push(v)
return arr
})
const gridBills = computed(() => {
const step = Math.ceil(maxBill.value / 4 / 100) * 100
const arr: number[] = []
for (let b = step; b < maxBill.value; b += step) arr.push(b)
return arr
})
const gridPrices2 = computed(() => {
const mx = baselinePriceMax.value
const step = mx > 5 ? Math.ceil(mx / 4) : mx > 2 ? 1 : 0.5
const arr: number[] = []
for (let p = step; p <= mx; p += step) arr.push(p)
return arr
})
function toPolyline(vols: number[] | undefined, vals: number[] | undefined, cyFn: (v: number) => number): string {
if (!vols?.length || !vals?.length) return ''
const pts: string[] = []
for (let i = 0; i < vols.length; i += 4) {
pts.push(`${cx2(vols[i]!)},${cyFn(vals[i]!)}`)
}
const last = vols.length - 1
if (last % 4 !== 0) pts.push(`${cx2(vols[last]!)},${cyFn(vals[last]!)}`)
return pts.join(' ')
}
const baselineBillRP = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_bills_rp, cy2bill))
const baselinePriceRP = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_price_m3_rp, cy2price))
const baselineBillRS = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_bills_rs, cy2bill))
const baselinePriceRS = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_price_m3_rs, cy2price))
// ── Chart 3: Marginal price + household distribution (full-width, population focus, inverted X) ──
const Wmarg = 700
const Hmarg = 380
const margMargin = { top: 24, right: 52, bottom: 48, left: 42 }
const margPlotW = Wmarg - margMargin.left - margMargin.right
const margPlotH = Hmarg - margMargin.top - margMargin.bottom
// Focus on population: 0 to ~600 m³
const margVolMax = computed(() => {
if (!distBuckets30.value.length) return 600
// Find last bucket with significant count
const lastSig = [...distBuckets30.value].reverse().find(b => b.count >= 2)
return lastSig ? Math.min(Math.ceil((lastSig.high + 60) / 30) * 30, vmax.value) : 600
})
const margPriceMax = computed(() => {
if (!curveData.value?.p0_linear) return 5
// Cap to show detail around p0_linear; abo/v shoots up at tiny volumes so don't let it dominate
// Target: see the price at the first 30m³ bucket comfortably (abo/30 + p0 ≈ 3.3 + 1.x ≈ 5)
const p0lin = curveData.value.p0_linear
const aboOverThirty = abop.value / 30
return Math.ceil((aboOverThirty + p0lin) * 1.4)
})
// Inverted X: high volumes left, 0 right
function margCx(v: number): number {
return margMargin.left + margPlotW - (v / margVolMax.value) * margPlotW
}
function margCyPrice(p: number): number {
return margMargin.top + margPlotH - (p / margPriceMax.value) * margPlotH
}
// Distribution (30m³ buckets)
const distBuckets30 = ref<Array<{ low: number; high: number; count: number }>>([])
// Filtered buckets visible in each chart's current zoom/range
const visibleBuckets30Main = computed(() => {
return distBuckets30.value.filter(b =>
b.high > zoomVolMin.value && b.low < zoomVolMax.value && b.count > 0
)
})
const visibleBuckets30Baseline = computed(() => {
return distBuckets30.value.filter(b => b.low < baselineVolMax.value && b.count > 0)
})
const margMaxCount = computed(() => {
if (!distBuckets30.value.length) return 10
return Math.max(...distBuckets30.value.map(b => b.count)) * 1.2
})
function margCyCount(c: number): number {
return margMargin.top + margPlotH - (c / margMaxCount.value) * margPlotH
}
const margGridVols = computed(() => {
const step = margVolMax.value > 300 ? 60 : 30
const arr: number[] = []
for (let v = step; v < margVolMax.value; v += step) arr.push(v)
return arr
})
const margGridPrices = computed(() => {
const mx = margPriceMax.value
const step = mx > 5 ? Math.ceil(mx / 4) : mx > 2 ? 1 : 0.5
const arr: number[] = []
for (let p = step; p < mx; p += step) arr.push(p)
return arr
})
const margGridCounts = computed(() => {
const mx = margMaxCount.value
const step = Math.max(1, Math.ceil(mx / 4 / 10) * 10)
const arr: number[] = []
for (let c = step; c < mx; c += step) arr.push(c)
return arr
})
const margPriceLine = computed(() => {
const vols = curveData.value?.baseline_volumes
const prices = curveData.value?.baseline_price_m3_rp
if (!vols?.length || !prices?.length) return ''
const pts: string[] = []
for (let i = 0; i < vols.length; i += 3) {
const v = vols[i]!
if (v > margVolMax.value) break
pts.push(`${margCx(v)},${margCyPrice(prices[i]!)}`)
}
return pts.join(' ')
})
// Prix moyen avec abonnement citoyen sur le graphique marginal (linear model)
const margAvgPriceWithAboLine = computed(() => {
if (citizenAbo.value <= 0) return ''
const vols = curveData.value?.baseline_volumes
if (!vols?.length) return ''
const p0lin = curveData.value?.p0_linear || 0
const pts: string[] = []
for (let i = 0; i < vols.length; i += 3) {
const v = vols[i]!
if (v < 1 || v > margVolMax.value) continue
const avgP = citizenAbo.value / v + p0lin
if (avgP <= margPriceMax.value * 1.5) {
pts.push(`${margCx(v)},${margCyPrice(avgP)}`)
}
}
return pts.join(' ')
})
// Household dots for baseline bill chart (bill = abo + p0_linear * volume)
const householdDotsBill = computed(() => {
if (!householdVolumes.value.length || !curveData.value?.p0_linear) return []
const p0lin = curveData.value.p0_linear
return householdVolumes.value.map(hh => {
const abo = hh.status === 'RS' ? abos.value : abop.value
return { volume: hh.volume_m3, bill: abo + p0lin * hh.volume_m3 }
})
})
// Household dots for baseline price chart (price = abo/v + p0_linear)
const householdDotsBaselinePrice = computed(() => {
if (!householdVolumes.value.length || !curveData.value?.p0_linear) return []
const p0lin = curveData.value.p0_linear
return householdVolumes.value
.filter(hh => hh.volume_m3 > 0)
.map(hh => {
const abo = hh.status === 'RS' ? abos.value : abop.value
return { volume: hh.volume_m3, price: abo / hh.volume_m3 + p0lin }
})
})
// Household dots for marginal price chart (linear model: price = abop/v + p0_linear)
const householdDotsMarg = computed(() => {
if (!householdVolumes.value.length || !curveData.value?.p0_linear) return []
const p0lin = curveData.value.p0_linear
return householdVolumes.value
.filter(hh => hh.volume_m3 > 0 && hh.volume_m3 <= margVolMax.value)
.map(hh => {
const v = hh.volume_m3
const abo = hh.status === 'RS' ? abos.value : abop.value
const price = abo / v + p0lin
return { volume: v, price }
})
})
// ── Auth & vote ──
async function authenticate() {
authError.value = ''
authLoading.value = true
try {
const data = await api.post<{ access_token: string; role: string; commune_slug: string; volume_m3?: number }>(
'/auth/citizen/verify',
{ commune_slug: slug, auth_code: authCode.value.toUpperCase() },
)
authStore.setAuth(data.access_token, data.role, data.commune_slug)
if (data.volume_m3 != null) citizenVolume.value = data.volume_m3
} catch (e: any) {
authError.value = e.message || 'Code invalide'
} finally {
authLoading.value = false
}
}
async function submitVote() {
submitting.value = true
voteSuccess.value = false
try {
const voteResult = await api.post<any>(`/communes/${slug}/votes`, {
vinf: bp.vinf, a: bp.a, b: bp.b, c: bp.c, d: bp.d, e: bp.e,
})
voteSuccess.value = true
if (voteResult?.submitted_at) {
lastVoteDate.value = new Date(voteResult.submitted_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' })
} else {
lastVoteDate.value = new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
} catch (e: any) {
alert(e.message || 'Erreur lors de la soumission')
} finally {
submitting.value = false
}
}
// ── Load data ──
onMounted(async () => {
try {
const [c, p, curve, pages] = await Promise.all([
api.get<any>(`/communes/${slug}`),
api.get<any>(`/communes/${slug}/params`),
api.get<any>(`/communes/${slug}/votes/current`),
api.get<any[]>(`/communes/${slug}/content`).catch(() => []),
])
contentPages.value = pages
commune.value = c
params.value = p
curveData.value = curve
vmax.value = p.vmax
pmax.value = p.pmax
recettes.value = p.recettes
abop.value = p.abop
abos.value = p.abos
differentiatedTariff.value = p.differentiated_tariff || false
zoomVolMax.value = p.vmax
zoomPriceMax.value = p.pmax
// Use published curve as initial state for citizen editing,
// but display the median as the result of votes
if (curve.published) {
bp.vinf = curve.published.vinf
bp.a = curve.published.a; bp.b = curve.published.b
bp.c = curve.published.c; bp.d = curve.published.d; bp.e = curve.published.e
} else if (curve.median) {
bp.vinf = curve.median.vinf
bp.a = curve.median.a; bp.b = curve.median.b
bp.c = curve.median.c; bp.d = curve.median.d; bp.e = curve.median.e
}
localP0.value = curve.p0
impacts.value = curve.impacts || []
const stats = await api.get<any>(`/communes/${slug}/households/stats`)
const hh: HouseholdData[] = []
const avgVol = stats.avg_volume || 90
for (let i = 0; i < (stats.rs_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'RS' })
for (let i = 0; i < (stats.rp_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'RP' })
for (let i = 0; i < (stats.pro_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'PRO' })
households.value = hh
try {
outlierVotes.value = await api.get<any[]>(`/communes/${slug}/votes/current/overlay`)
} catch {}
try {
const dist = await api.get<any>(`/communes/${slug}/households/distribution?bucket_size=50`)
distBuckets.value = dist.buckets || []
} catch {}
try {
const dist30 = await api.get<any>(`/communes/${slug}/households/distribution?bucket_size=30`)
distBuckets30.value = dist30.buckets || []
} catch {}
try {
householdVolumes.value = await api.get<any[]>(`/communes/${slug}/households/volumes`)
} catch {}
zoomPreset('tier1')
if (c.vote_deadline) {
updateCountdown()
countdownInterval = setInterval(updateCountdown, 1000)
}
} catch (e: any) {
loadError.value = e.message
} finally {
loading.value = false
}
})
function renderMarkdown(md: string): string {
if (!md) return ''
return md
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(?!<[hulo])(.+)$/gm, '<p>$1</p>')
}
</script>
<style scoped>
/* ══════════════════════════════════════
ADAPTIVE CSS — mobile-first + clamp()
Breakpoints: 480 / 768 / 1024
Chart density via --chart-scale (set by DisplaySettings)
══════════════════════════════════════ */
/* ── Chart density support ── */
.chart-container, .chart-main {
transform: scale(var(--chart-scale, 1));
transform-origin: top center;
}
/* ── Chart cards ── */
.chart-card {
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);
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.chart-title {
font-size: clamp(0.95rem, 2.5vw, 1.1rem);
font-weight: 700;
color: var(--color-text);
margin-bottom: 0.15rem;
}
.chart-subtitle {
font-size: clamp(0.72rem, 1.8vw, 0.82rem);
color: var(--color-text-muted);
}
/* ── Zoom bar ── */
.zoom-bar {
display: flex;
gap: 0.3rem;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.zoom-info {
font-size: 0.72rem;
color: var(--color-text-muted);
margin-left: 0.25rem;
}
.btn-xs {
padding: 0.2rem 0.6rem;
font-size: 0.72rem;
border-radius: 6px;
font-weight: 500;
}
.toggle-label {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: clamp(0.72rem, 1.8vw, 0.8rem);
color: var(--color-text-muted);
cursor: pointer;
}
.toggle-label input { cursor: pointer; }
.zoom-separator {
width: 1px;
height: 18px;
background: var(--color-border);
margin: 0 0.25rem;
}
@media (max-width: 480px) {
.zoom-separator { display: none; }
.zoom-bar { gap: 0.2rem; }
}
.abo-input-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--color-text-muted);
font-weight: 500;
}
.abo-input {
width: 56px;
padding: 0.15rem 0.35rem;
border: 1px solid var(--color-border);
border-radius: 5px;
font-size: 0.78rem;
text-align: right;
font-family: monospace;
background: var(--color-surface);
color: var(--color-text);
}
.abo-input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
}
/* ── SVG axes ── */
.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 {
width: 100%;
height: auto;
user-select: none;
}
.chart-main svg {
border-radius: 8px;
}
.chart-container svg path {
stroke-linecap: round;
stroke-linejoin: round;
}
/* ── Drag handles ── */
.drag-handle {
cursor: grab;
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.15));
transition: r 0.15s ease;
}
.drag-handle:hover { r: 10; }
/* ── Main layout: chart + sidebar ── */
.main-layout {
display: grid;
grid-template-columns: 1fr 260px;
gap: clamp(0.75rem, 2vw, 1rem);
align-items: start;
margin-bottom: 1.5rem;
}
@media (max-width: 1024px) {
.main-layout { grid-template-columns: 1fr 220px; }
}
@media (max-width: 768px) {
.main-layout { grid-template-columns: 1fr; }
}
.main-layout-chart { min-width: 0; }
.main-layout-sidebar {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@media (max-width: 768px) {
.main-layout-sidebar {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
}
@media (max-width: 480px) {
.main-layout-sidebar { grid-template-columns: 1fr; }
}
.sidebar-card {
padding: clamp(0.65rem, 2vw, 0.85rem) !important;
font-size: 0.85rem;
}
.sidebar-auth-block h4 {
font-size: 0.9rem;
font-weight: 700;
margin-bottom: 0.15rem;
}
.sidebar-muted {
font-size: 0.72rem;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.countdown-compact {
display: flex;
gap: 0.5rem;
font-size: 1rem;
}
.countdown-compact span strong {
font-weight: 700;
}
/* ── Section title ── */
.section-title {
font-size: clamp(1rem, 2.5vw, 1.15rem);
font-weight: 700;
color: var(--color-text);
margin: clamp(1rem, 3vw, 1.5rem) 0 clamp(0.75rem, 2vw, 1rem);
padding-bottom: 0.4rem;
border-bottom: 2px solid var(--color-border);
}
/* ── Params + impacts ── */
.params-impacts-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 768px) { .params-impacts-layout { grid-template-columns: 1fr; } }
.param-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem 1rem;
}
.param-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.15rem 0;
}
.param-label { font-size: 0.75rem; color: var(--color-text-muted); }
.param-val { font-family: monospace; font-weight: 600; font-size: 0.85rem; }
.table-sm { font-size: clamp(0.72rem, 1.8vw, 0.8rem); }
.table-sm th, .table-sm td { padding: 0.3rem 0.5rem; }
.text-up { color: #dc2626; font-weight: 600; }
.text-down { color: #059669; font-weight: 600; }
/* ── Baseline charts ── */
.baseline-charts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 768px) { .baseline-charts { grid-template-columns: 1fr; } }
/* ── Key metrics banner ── */
.key-metrics-banner {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: clamp(0.5rem, 2vw, 1rem);
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);
}
@media (max-width: 1024px) { .key-metrics-banner { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 600px) { .key-metrics-banner { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 380px) { .key-metrics-banner { grid-template-columns: 1fr; } }
.key-metric {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.2rem;
}
.key-metric-value {
font-size: clamp(1.1rem, 3vw, 1.4rem);
font-weight: 700;
color: var(--color-text);
}
.key-metric-label {
font-size: clamp(0.68rem, 1.6vw, 0.78rem);
color: var(--color-text-muted);
font-weight: 500;
}
.key-metric-tag {
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.5rem;
border-radius: 20px;
background: var(--color-bg);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.key-metric-tag-calc {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
opacity: 0.85;
}
.key-metric-input {
opacity: 0.85;
}
.key-metric-result {}
.key-metric-highlight {
background: rgba(5, 150, 105, 0.06);
border-radius: 8px;
padding: 0.5rem;
}
.key-metric-highlight .key-metric-value {
color: #059669;
}
/* ── Vote result rows ── */
.vote-result-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: clamp(0.72rem, 1.8vw, 0.8rem);
padding: 0.2rem 0;
color: var(--color-text-muted);
}
.vote-result-row strong {
font-family: monospace;
font-size: 0.88rem;
color: var(--color-text);
}
/* ── Alerts ── */
.alert-success {
background: #dcfce7;
color: #166534;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.85rem;
}
/* ── Page header adaptive ── */
.page-header h1 {
font-size: clamp(1.25rem, 4vw, 1.75rem);
}
/* ── CMS ── */
.cms-body { line-height: 1.7; font-size: clamp(0.82rem, 2vw, 0.9rem); }
.cms-body :deep(h2) { font-size: clamp(1.05rem, 2.5vw, 1.2rem); margin: 0.75rem 0 0.5rem; }
.cms-body :deep(h3) { font-size: 1.05rem; margin: 0.5rem 0 0.25rem; }
.cms-body :deep(p) { margin: 0.5rem 0; }
.cms-body :deep(a) { color: var(--color-primary); }
.cms-body :deep(ul) { margin: 0.5rem 0; padding-left: 1.5rem; }
</style>