- 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>
1920 lines
81 KiB
Vue
1920 lines
81 KiB
Vue
<template>
|
||
<div v-if="commune">
|
||
<!-- ── 1. Header ── -->
|
||
<div class="page-header">
|
||
<NuxtLink to="/" style="color: var(--color-text-muted); font-size: 0.875rem;">← 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">−</button>
|
||
<button class="btn btn-secondary btn-xs" @click="zoomReset">Reset</button>
|
||
<span class="zoom-info">
|
||
{{ zoomVolMin.toFixed(0) }}–{{ zoomVolMax.toFixed(0) }} m³
|
||
</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 }} m³
|
||
</text>
|
||
<!-- Household counts per 30m³ 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/30m³</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 (m³) → reduire
|
||
</text>
|
||
<text :x="W - 4" :y="margin.top - 6" text-anchor="end" class="axis-title">
|
||
€/m³
|
||
</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) }} €/m³
|
||
</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) }} €/m³
|
||
</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) }}€/m³)
|
||
</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 (60m³)
|
||
</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 (m³/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) }} m³
|
||
</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 (m³)
|
||
</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) }} m³</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) }} €/m³</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) }} €/m³</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 m³</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) }} m³</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) }} €/m³</strong>
|
||
</div>
|
||
<div class="vote-result-row">
|
||
<span>un prix median de</span>
|
||
<strong>{{ medianPrice.toFixed(2) }} €/m³</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 }} m³</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) }} €/m³ + 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 30m³ -->
|
||
<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">m³ → 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 m³ (€)</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 30m³ -->
|
||
<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) }}€/m³
|
||
</text>
|
||
<text :x="W2/2" :y="margin2.top + plotH2 + 42" text-anchor="middle" class="axis-label-sm">m³ → 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 m³ + 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 m³ — 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 }}m³</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) }}€/m³
|
||
</text>
|
||
|
||
<!-- Axis titles -->
|
||
<text :x="Wmarg / 2" :y="margMargin.top + margPlotH + 40" text-anchor="middle" class="axis-title">
|
||
Consommation (m³) → 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: €/m³ -->
|
||
<text :x="Wmarg - 4" :y="margMargin.top - 6" text-anchor="end" class="axis-title">
|
||
€/m³
|
||
</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 m³ avec abonnement (€/m³)</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 30m³</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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.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>
|