|
|
|
|
@@ -1,5 +1,6 @@
|
|
|
|
|
<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>
|
|
|
|
|
@@ -13,16 +14,9 @@
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<template v-else-if="curveData">
|
|
|
|
|
<!-- CMS content (published by admin) -->
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
<!-- ═══════════════════════════════════════════════════════
|
|
|
|
|
GRAPH 1: Interactive Bezier curve — Prix au m3
|
|
|
|
|
2. COURBE INTERACTIVE — Position dominante, pleine largeur
|
|
|
|
|
═══════════════════════════════════════════════════════ -->
|
|
|
|
|
<div class="card" style="margin-bottom: 1.5rem;">
|
|
|
|
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
|
|
|
|
|
@@ -45,8 +39,8 @@
|
|
|
|
|
|
|
|
|
|
<!-- Zoom controls -->
|
|
|
|
|
<div class="zoom-bar">
|
|
|
|
|
<button class="btn btn-secondary btn-xs" @click="zoomPreset('tier1')" title="Zoom tier 1">Tier 1</button>
|
|
|
|
|
<button class="btn btn-secondary btn-xs" @click="zoomPreset('tier2')" title="Zoom tier 2">Tier 2</button>
|
|
|
|
|
<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>
|
|
|
|
|
@@ -55,183 +49,236 @@
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="editor-layout">
|
|
|
|
|
<!-- SVG: Prix au m3 vs Volume (the Bezier curve) -->
|
|
|
|
|
<div class="chart-container">
|
|
|
|
|
<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"
|
|
|
|
|
>
|
|
|
|
|
<!-- Grid -->
|
|
|
|
|
<g>
|
|
|
|
|
<line v-for="v in gridVols" :key="'gv'+v"
|
|
|
|
|
:x1="cx(v)" :y1="cy(0)" :x2="cx(v)" :y2="cy(zoomPriceMax)"
|
|
|
|
|
stroke="#e2e8f0" stroke-width="0.5" />
|
|
|
|
|
<line v-for="p in gridPrices" :key="'gp'+p"
|
|
|
|
|
:x1="cx(zoomVolMin)" :y1="cy(p)" :x2="cx(zoomVolMax)" :y2="cy(p)"
|
|
|
|
|
stroke="#e2e8f0" stroke-width="0.5" />
|
|
|
|
|
<text v-for="v in gridVols" :key="'lv'+v"
|
|
|
|
|
:x="cx(v)" :y="H - 2" text-anchor="middle" font-size="10" fill="#94a3b8">
|
|
|
|
|
{{ v }}
|
|
|
|
|
</text>
|
|
|
|
|
<text v-for="p in gridPrices" :key="'lp'+p"
|
|
|
|
|
:x="margin.left - 4" :y="cy(p) + 3" text-anchor="end" font-size="10" fill="#94a3b8">
|
|
|
|
|
{{ p.toFixed(p < 1 ? 1 : 0) }}
|
|
|
|
|
</text>
|
|
|
|
|
<text :x="W / 2" :y="H - 0" text-anchor="middle" font-size="10" fill="#64748b">
|
|
|
|
|
volume (m3)
|
|
|
|
|
</text>
|
|
|
|
|
<text :x="12" :y="margin.top - 4" font-size="10" fill="#64748b">
|
|
|
|
|
EUR/m3
|
|
|
|
|
</text>
|
|
|
|
|
</g>
|
|
|
|
|
|
|
|
|
|
<!-- Outlier vote curves (semi-transparent) -->
|
|
|
|
|
<g v-if="showOutliers && outlierVotes.length">
|
|
|
|
|
<template v-for="(vote, i) in outlierVotes" :key="'ov'+i">
|
|
|
|
|
<path :d="outlierPath(vote, 1)" fill="none" stroke="#93c5fd" stroke-width="1" opacity="0.25" />
|
|
|
|
|
<path :d="outlierPath(vote, 2)" fill="none" stroke="#fca5a5" stroke-width="1" opacity="0.25" />
|
|
|
|
|
</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="4" />
|
|
|
|
|
<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="4" />
|
|
|
|
|
<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="4" />
|
|
|
|
|
<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="4" />
|
|
|
|
|
|
|
|
|
|
<!-- Bezier curve: tier 1 (blue, thicker = focus) -->
|
|
|
|
|
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="3" />
|
|
|
|
|
<!-- Bezier curve: tier 2 (red) -->
|
|
|
|
|
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2" />
|
|
|
|
|
|
|
|
|
|
<!-- Inflection reference lines -->
|
|
|
|
|
<line :x1="cx(bp.vinf)" :y1="cy(zoomPriceMin)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
|
|
|
|
|
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
|
|
|
|
<line :x1="cx(zoomVolMin)" :y1="cy(localP0)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
|
|
|
|
|
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
|
|
|
|
|
|
|
|
|
<!-- p0 label -->
|
|
|
|
|
<text :x="cx(zoomVolMin) + 30" :y="cy(localP0) - 6" font-size="12" fill="#1e293b" font-weight="600">
|
|
|
|
|
p0 = {{ localP0.toFixed(2) }} EUR/m3
|
|
|
|
|
<!-- SVG: full-width, no side panel -->
|
|
|
|
|
<div class="chart-container">
|
|
|
|
|
<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"
|
|
|
|
|
>
|
|
|
|
|
<!-- Grid -->
|
|
|
|
|
<g>
|
|
|
|
|
<line v-for="v in gridVols" :key="'gv'+v"
|
|
|
|
|
:x1="cx(v)" :y1="cy(0)" :x2="cx(v)" :y2="cy(zoomPriceMax)"
|
|
|
|
|
stroke="#e2e8f0" stroke-width="0.5" />
|
|
|
|
|
<line v-for="p in gridPrices" :key="'gp'+p"
|
|
|
|
|
:x1="cx(zoomVolMin)" :y1="cy(p)" :x2="cx(zoomVolMax)" :y2="cy(p)"
|
|
|
|
|
stroke="#e2e8f0" stroke-width="0.5" />
|
|
|
|
|
<text v-for="v in gridVols" :key="'lv'+v"
|
|
|
|
|
:x="cx(v)" :y="H - 2" text-anchor="middle" font-size="12" fill="#64748b">
|
|
|
|
|
{{ v }}
|
|
|
|
|
</text>
|
|
|
|
|
|
|
|
|
|
<!-- Draggable control points -->
|
|
|
|
|
<circle v-for="(pt, key) in dragPoints" :key="key"
|
|
|
|
|
:cx="cx(pt.x)" :cy="cy(pt.y)"
|
|
|
|
|
:r="dragging === key ? 9 : 7"
|
|
|
|
|
:fill="ptColors[key]" stroke="white" stroke-width="2"
|
|
|
|
|
style="cursor: grab;"
|
|
|
|
|
@mousedown.prevent="startDrag(key)"
|
|
|
|
|
@touchstart.prevent="startDrag(key)"
|
|
|
|
|
/>
|
|
|
|
|
<text v-for="(pt, key) in dragPoints" :key="'l-'+key"
|
|
|
|
|
:x="cx(pt.x) + 10" :y="cy(pt.y) - 10"
|
|
|
|
|
font-size="11" :fill="ptColors[key]" font-weight="500">
|
|
|
|
|
{{ ptLabels[key] }}
|
|
|
|
|
<text v-for="p in gridPrices" :key="'lp'+p"
|
|
|
|
|
:x="margin.left - 4" :y="cy(p) + 3" text-anchor="end" font-size="12" fill="#64748b">
|
|
|
|
|
{{ p.toFixed(p < 1 ? 1 : 0) }}
|
|
|
|
|
</text>
|
|
|
|
|
</svg>
|
|
|
|
|
<text :x="W / 2" :y="H - 0" text-anchor="middle" font-size="12" fill="#475569">
|
|
|
|
|
volume (m3)
|
|
|
|
|
</text>
|
|
|
|
|
<text :x="12" :y="margin.top - 4" font-size="12" fill="#475569">
|
|
|
|
|
EUR/m3
|
|
|
|
|
</text>
|
|
|
|
|
</g>
|
|
|
|
|
|
|
|
|
|
<!-- Outlier vote curves (semi-transparent) -->
|
|
|
|
|
<g v-if="showOutliers && outlierVotes.length">
|
|
|
|
|
<template v-for="(vote, i) in outlierVotes" :key="'ov'+i">
|
|
|
|
|
<path :d="outlierPath(vote, 1)" fill="none" stroke="#93c5fd" stroke-width="1" opacity="0.25" />
|
|
|
|
|
<path :d="outlierPath(vote, 2)" fill="none" stroke="#fca5a5" stroke-width="1" opacity="0.25" />
|
|
|
|
|
</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="4" />
|
|
|
|
|
<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="4" />
|
|
|
|
|
<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="4" />
|
|
|
|
|
<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="4" />
|
|
|
|
|
|
|
|
|
|
<!-- Bezier curve: population (blue, thicker = focus) -->
|
|
|
|
|
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="3" />
|
|
|
|
|
<!-- Bezier curve: cas exceptionnels (red) -->
|
|
|
|
|
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2" />
|
|
|
|
|
|
|
|
|
|
<!-- Inflection reference lines -->
|
|
|
|
|
<line :x1="cx(bp.vinf)" :y1="cy(zoomPriceMin)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
|
|
|
|
|
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
|
|
|
|
<line :x1="cx(zoomVolMin)" :y1="cy(localP0)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
|
|
|
|
|
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
|
|
|
|
|
|
|
|
|
<!-- p0 label -->
|
|
|
|
|
<text :x="cx(zoomVolMin) + 30" :y="cy(localP0) - 6" font-size="12" fill="#1e293b" font-weight="600">
|
|
|
|
|
p0 = {{ localP0.toFixed(2) }} EUR/m3
|
|
|
|
|
</text>
|
|
|
|
|
|
|
|
|
|
<!-- Draggable control points -->
|
|
|
|
|
<circle v-for="(pt, key) in dragPoints" :key="key"
|
|
|
|
|
:cx="cx(pt.x)" :cy="cy(pt.y)"
|
|
|
|
|
:r="dragging === key ? 9 : 7"
|
|
|
|
|
:fill="ptColors[key]" stroke="white" stroke-width="2"
|
|
|
|
|
style="cursor: grab;"
|
|
|
|
|
@mousedown.prevent="startDrag(key)"
|
|
|
|
|
@touchstart.prevent="startDrag(key)"
|
|
|
|
|
/>
|
|
|
|
|
<text v-for="(pt, key) in dragPoints" :key="'l-'+key"
|
|
|
|
|
:x="cx(pt.x) + 10" :y="cy(pt.y) - 10"
|
|
|
|
|
font-size="11" :fill="ptColors[key]" font-weight="500">
|
|
|
|
|
{{ ptLabels[key] }}
|
|
|
|
|
</text>
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- ═══════════════════════════════════════════════════════
|
|
|
|
|
3. SECTION AUTHENTIFICATION
|
|
|
|
|
═══════════════════════════════════════════════════════ -->
|
|
|
|
|
<div class="card" style="margin-bottom: 1.5rem;">
|
|
|
|
|
<h3 style="margin-bottom: 0.75rem;">Authentification</h3>
|
|
|
|
|
|
|
|
|
|
<div v-if="isCitizenAuth" class="alert alert-success">
|
|
|
|
|
Vous etes authentifie. Vous pouvez ajuster la courbe et sauvegarder votre vote.
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-else class="auth-options">
|
|
|
|
|
<!-- Code foyer -->
|
|
|
|
|
<div class="auth-option">
|
|
|
|
|
<h4>Code foyer</h4>
|
|
|
|
|
<p style="font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">
|
|
|
|
|
Entrez le code a 8 caracteres recu par courrier.
|
|
|
|
|
</p>
|
|
|
|
|
<div v-if="authError" class="alert alert-error" style="margin-bottom: 0.5rem;">{{ authError }}</div>
|
|
|
|
|
<form @submit.prevent="authenticate" style="display: flex; gap: 0.5rem;">
|
|
|
|
|
<input v-model="authCode" type="text" maxlength="8" placeholder="Code foyer"
|
|
|
|
|
class="form-input" style="flex: 1; text-transform: uppercase; font-family: monospace; letter-spacing: 0.15em;" />
|
|
|
|
|
<button type="submit" class="btn btn-primary" :disabled="authLoading">OK</button>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Right panel: parameters + impacts -->
|
|
|
|
|
<div class="side-panel">
|
|
|
|
|
<div class="card" style="margin-bottom: 1rem;">
|
|
|
|
|
<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) }} m3</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 class="param-row" style="grid-column: span 2; border-top: 1px solid var(--color-border); padding-top: 0.5rem;">
|
|
|
|
|
<span class="param-label" style="font-weight: 600;">p<sub>0</sub></span>
|
|
|
|
|
<span class="param-val" style="font-size: 1.1rem;">{{ localP0.toFixed(2) }} EUR/m3</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Impact table -->
|
|
|
|
|
<div class="card" style="margin-bottom: 1rem;">
|
|
|
|
|
<h4 style="margin-bottom: 0.5rem;">Impact par volume</h4>
|
|
|
|
|
<table class="table table-sm">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr><th>Vol.</th><th>Ancien</th><th>Nouveau RP</th><th>Nouveau RS</th></tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr v-for="imp in impacts" :key="imp.volume">
|
|
|
|
|
<td>{{ imp.volume }} m3</td>
|
|
|
|
|
<td>{{ imp.old_price.toFixed(0) }} EUR</td>
|
|
|
|
|
<td :class="imp.new_price_rp > imp.old_price ? 'text-up' : 'text-down'">
|
|
|
|
|
{{ imp.new_price_rp.toFixed(0) }} EUR
|
|
|
|
|
</td>
|
|
|
|
|
<td :class="imp.new_price_rs > imp.old_price ? 'text-up' : 'text-down'">
|
|
|
|
|
{{ imp.new_price_rs.toFixed(0) }} EUR
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Vote action -->
|
|
|
|
|
<div class="card">
|
|
|
|
|
<div v-if="!isCitizenAuth">
|
|
|
|
|
<p style="font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">
|
|
|
|
|
Pour soumettre votre vote, entrez votre code foyer :
|
|
|
|
|
</p>
|
|
|
|
|
<div v-if="authError" class="alert alert-error" style="margin-bottom: 0.5rem;">{{ authError }}</div>
|
|
|
|
|
<form @submit.prevent="authenticate" style="display: flex; gap: 0.5rem;">
|
|
|
|
|
<input v-model="authCode" type="text" maxlength="8" placeholder="Code foyer"
|
|
|
|
|
class="form-input" style="flex: 1; text-transform: uppercase; font-family: monospace; letter-spacing: 0.15em;" />
|
|
|
|
|
<button type="submit" class="btn btn-primary" :disabled="authLoading">OK</button>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else>
|
|
|
|
|
<button class="btn btn-primary" style="width: 100%;" @click="submitVote" :disabled="submitting">
|
|
|
|
|
<span v-if="submitting" class="spinner" style="width: 1rem; height: 1rem;"></span>
|
|
|
|
|
Soumettre mon vote
|
|
|
|
|
</button>
|
|
|
|
|
<div v-if="voteSuccess" class="alert alert-success" style="margin-top: 0.5rem;">
|
|
|
|
|
Vote enregistre !
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<!-- Duniter -->
|
|
|
|
|
<div class="auth-option" style="opacity: 0.5;">
|
|
|
|
|
<h4>Duniter</h4>
|
|
|
|
|
<p style="font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">
|
|
|
|
|
Authentification par identite numerique Duniter.
|
|
|
|
|
</p>
|
|
|
|
|
<button class="btn btn-secondary" disabled>Bientot disponible</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- ═══════════════════════════════════════════════════════
|
|
|
|
|
GRAPH 2: Static baseline — Modele lineaire actuel
|
|
|
|
|
(= 1er graph de eau.py — CurrentModel)
|
|
|
|
|
4. SECTION VOTE
|
|
|
|
|
═══════════════════════════════════════════════════════ -->
|
|
|
|
|
<div class="card" style="margin-bottom: 1.5rem;">
|
|
|
|
|
<h3 style="margin-bottom: 0.5rem;">Votre vote</h3>
|
|
|
|
|
<p style="font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 1rem;">
|
|
|
|
|
Ajustez la courbe ci-dessus, puis sauvegardez. C'est votre vote. Modifiable a tout moment. La derniere position compte.
|
|
|
|
|
</p>
|
|
|
|
|
<button
|
|
|
|
|
class="btn btn-primary btn-lg"
|
|
|
|
|
@click="submitVote"
|
|
|
|
|
:disabled="!isCitizenAuth || submitting || isVoteClosed"
|
|
|
|
|
>
|
|
|
|
|
<span v-if="submitting" class="spinner" style="width: 1rem; height: 1rem;"></span>
|
|
|
|
|
Sauvegarder mon vote
|
|
|
|
|
</button>
|
|
|
|
|
<p v-if="!isCitizenAuth" style="font-size: 0.8rem; color: var(--color-text-muted); margin-top: 0.5rem;">
|
|
|
|
|
Authentifiez-vous pour voter.
|
|
|
|
|
</p>
|
|
|
|
|
<p v-else-if="isVoteClosed" style="font-size: 0.8rem; color: #dc2626; margin-top: 0.5rem;">
|
|
|
|
|
Le vote est clos.
|
|
|
|
|
</p>
|
|
|
|
|
<div v-if="voteSuccess" class="alert alert-success" style="margin-top: 0.75rem;">
|
|
|
|
|
Vote enregistre !
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- ═══════════════════════════════════════════════════════
|
|
|
|
|
5. COMPTE A REBOURS
|
|
|
|
|
═══════════════════════════════════════════════════════ -->
|
|
|
|
|
<div v-if="commune.vote_deadline" class="card" style="margin-bottom: 1.5rem;">
|
|
|
|
|
<h3 style="margin-bottom: 0.5rem;">Echeance du vote</h3>
|
|
|
|
|
<div v-if="isVoteClosed" style="font-size: 1.1rem; color: #dc2626; font-weight: 600;">
|
|
|
|
|
Le vote est clos.
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else class="countdown">
|
|
|
|
|
<span class="countdown-unit"><strong>{{ countdown.days }}</strong>j</span>
|
|
|
|
|
<span class="countdown-unit"><strong>{{ countdown.hours }}</strong>h</span>
|
|
|
|
|
<span class="countdown-unit"><strong>{{ countdown.minutes }}</strong>m</span>
|
|
|
|
|
<span class="countdown-unit"><strong>{{ countdown.seconds }}</strong>s</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- ═══════════════════════════════════════════════════════
|
|
|
|
|
6. PARAMETRES COURBE + TABLE IMPACTS (2 colonnes)
|
|
|
|
|
═══════════════════════════════════════════════════════ -->
|
|
|
|
|
<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) }} m3</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 class="param-row" style="grid-column: span 2; border-top: 1px solid var(--color-border); padding-top: 0.5rem;">
|
|
|
|
|
<span class="param-label" style="font-weight: 600;">p<sub>0</sub></span>
|
|
|
|
|
<span class="param-val" style="font-size: 1.1rem;">{{ localP0.toFixed(2) }} EUR/m3</span>
|
|
|
|
|
</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>Ancien</th><th>Nouveau RP</th><th>Nouveau RS</th></tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr v-for="imp in impacts" :key="imp.volume">
|
|
|
|
|
<td>{{ imp.volume }} m3</td>
|
|
|
|
|
<td>{{ imp.old_price.toFixed(0) }} EUR</td>
|
|
|
|
|
<td :class="imp.new_price_rp > imp.old_price ? 'text-up' : 'text-down'">
|
|
|
|
|
{{ imp.new_price_rp.toFixed(0) }} EUR
|
|
|
|
|
</td>
|
|
|
|
|
<td :class="imp.new_price_rs > imp.old_price ? 'text-up' : 'text-down'">
|
|
|
|
|
{{ imp.new_price_rs.toFixed(0) }} EUR
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- ═══════════════════════════════════════════════════════
|
|
|
|
|
7. TARIFICATION ACTUELLE (baseline lineaire)
|
|
|
|
|
═══════════════════════════════════════════════════════ -->
|
|
|
|
|
<div class="card" style="margin-bottom: 1.5rem;">
|
|
|
|
|
<h3 style="margin-bottom: 0.5rem;">Tarification actuelle (modele lineaire)</h3>
|
|
|
|
|
@@ -244,7 +291,6 @@
|
|
|
|
|
<div class="chart-container">
|
|
|
|
|
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem;">Facture totale (EUR)</h4>
|
|
|
|
|
<svg :viewBox="`0 0 ${W2} ${H2}`" preserveAspectRatio="xMidYMid meet">
|
|
|
|
|
<!-- Grid -->
|
|
|
|
|
<g>
|
|
|
|
|
<line v-for="v in gridVols2" :key="'bg1v'+v"
|
|
|
|
|
:x1="cx2(v)" :y1="cy2bill(0)" :x2="cx2(v)" :y2="cy2bill(maxBill)"
|
|
|
|
|
@@ -257,11 +303,8 @@
|
|
|
|
|
<text v-for="b in gridBills" :key="'bg1lb'+b"
|
|
|
|
|
:x="margin2.left - 4" :y="cy2bill(b) + 3" text-anchor="end" font-size="9" fill="#94a3b8">{{ b }}</text>
|
|
|
|
|
</g>
|
|
|
|
|
<!-- RP curve -->
|
|
|
|
|
<polyline :points="baselineBillRP" fill="none" stroke="#2563eb" stroke-width="1.5" />
|
|
|
|
|
<!-- RS curve -->
|
|
|
|
|
<polyline :points="baselineBillRS" fill="none" stroke="#dc2626" stroke-width="1.5" />
|
|
|
|
|
<!-- Legend -->
|
|
|
|
|
<g :transform="`translate(${W2 - 100}, 15)`">
|
|
|
|
|
<line x1="0" y1="0" x2="15" y2="0" stroke="#2563eb" stroke-width="1.5" />
|
|
|
|
|
<text x="18" y="3" font-size="9" fill="#1e293b">RP/PRO</text>
|
|
|
|
|
@@ -276,7 +319,6 @@
|
|
|
|
|
<div class="chart-container">
|
|
|
|
|
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem;">Prix au m<sup>3</sup> (EUR)</h4>
|
|
|
|
|
<svg :viewBox="`0 0 ${W2} ${H2}`" preserveAspectRatio="xMidYMid meet">
|
|
|
|
|
<!-- Grid -->
|
|
|
|
|
<g>
|
|
|
|
|
<line v-for="v in gridVols2" :key="'bg2v'+v"
|
|
|
|
|
:x1="cx2(v)" :y1="cy2price(0)" :x2="cx2(v)" :y2="cy2price(pmax)"
|
|
|
|
|
@@ -289,11 +331,8 @@
|
|
|
|
|
<text v-for="p in gridPrices2" :key="'bg2lp'+p"
|
|
|
|
|
:x="margin2.left - 4" :y="cy2price(p) + 3" text-anchor="end" font-size="9" fill="#94a3b8">{{ p }}</text>
|
|
|
|
|
</g>
|
|
|
|
|
<!-- RP price/m3 curve (hyperbolic) -->
|
|
|
|
|
<polyline :points="baselinePriceRP" fill="none" stroke="#2563eb" stroke-width="1.5" />
|
|
|
|
|
<!-- RS price/m3 curve -->
|
|
|
|
|
<polyline :points="baselinePriceRS" fill="none" stroke="#dc2626" stroke-width="1.5" />
|
|
|
|
|
<!-- p0 baseline line -->
|
|
|
|
|
<line :x1="cx2(0)" :y1="cy2price(curveData.p0_linear)" :x2="cx2(vmax)" :y2="cy2price(curveData.p0_linear)"
|
|
|
|
|
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
|
|
|
|
<text :x="cx2(vmax) - 5" :y="cy2price(curveData.p0_linear) - 5" text-anchor="end"
|
|
|
|
|
@@ -306,8 +345,10 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Tariff params info -->
|
|
|
|
|
<div class="card">
|
|
|
|
|
<!-- ═══════════════════════════════════════════════════════
|
|
|
|
|
8. INFORMATIONS TARIFAIRES
|
|
|
|
|
═══════════════════════════════════════════════════════ -->
|
|
|
|
|
<div class="card" style="margin-bottom: 1.5rem;">
|
|
|
|
|
<h3 style="margin-bottom: 0.75rem;">Informations tarifaires</h3>
|
|
|
|
|
<div v-if="params" class="grid grid-5-info">
|
|
|
|
|
<div><strong>{{ params.recettes.toLocaleString() }} EUR</strong><br/><span class="info-label">Recettes cibles</span></div>
|
|
|
|
|
@@ -317,6 +358,17 @@
|
|
|
|
|
<div><strong>{{ params.vmax }} m3</strong><br/><span class="info-label">Volume max</span></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- ═══════════════════════════════════════════════════════
|
|
|
|
|
9. CONTENU CMS — deplace en fin de page
|
|
|
|
|
═══════════════════════════════════════════════════════ -->
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
@@ -367,6 +419,39 @@ const isCitizenAuth = computed(() => authStore.isCitizen && authStore.communeSlu
|
|
|
|
|
const submitting = ref(false)
|
|
|
|
|
const voteSuccess = ref(false)
|
|
|
|
|
|
|
|
|
|
// ── 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 ──
|
|
|
|
|
const W = 620
|
|
|
|
|
const H = 380
|
|
|
|
|
@@ -534,8 +619,6 @@ function handleDrag(pt: { x: number; y: number }) {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// p0 stays fixed during drag — curve updates reactively via bp → cp → SVG paths
|
|
|
|
|
// Server will compute authoritative p0 + impacts on mouseUp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let serverTimeout: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
@@ -691,10 +774,19 @@ onMounted(async () => {
|
|
|
|
|
for (let i = 0; i < (stats.pro_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'PRO' })
|
|
|
|
|
households.value = hh
|
|
|
|
|
|
|
|
|
|
// Load all vote curves for outlier overlay (public endpoint needed)
|
|
|
|
|
// Load all vote curves for outlier overlay
|
|
|
|
|
try {
|
|
|
|
|
outlierVotes.value = await api.get<any[]>(`/communes/${slug}/votes/current/overlay`)
|
|
|
|
|
} catch { /* endpoint may not exist yet */ }
|
|
|
|
|
|
|
|
|
|
// Default zoom to "Population" preset
|
|
|
|
|
zoomPreset('tier1')
|
|
|
|
|
|
|
|
|
|
// Start countdown if deadline set
|
|
|
|
|
if (c.vote_deadline) {
|
|
|
|
|
updateCountdown()
|
|
|
|
|
countdownInterval = setInterval(updateCountdown, 1000)
|
|
|
|
|
}
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
loadError.value = e.message
|
|
|
|
|
} finally {
|
|
|
|
|
@@ -746,24 +838,52 @@ function renderMarkdown(md: string): string {
|
|
|
|
|
|
|
|
|
|
.toggle-label input { cursor: pointer; }
|
|
|
|
|
|
|
|
|
|
.editor-layout {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 320px;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
}
|
|
|
|
|
@media (max-width: 900px) {
|
|
|
|
|
.editor-layout { grid-template-columns: 1fr; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chart-container svg {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: auto;
|
|
|
|
|
user-select: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.side-panel .card {
|
|
|
|
|
background: var(--color-bg);
|
|
|
|
|
.chart-container svg path {
|
|
|
|
|
stroke-linecap: round;
|
|
|
|
|
stroke-linejoin: round;
|
|
|
|
|
transition: stroke-width 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chart-container svg circle {
|
|
|
|
|
transition: r 0.2s ease, fill 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chart-container svg circle:hover {
|
|
|
|
|
r: 10;
|
|
|
|
|
fill: #1d4ed8;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Auth options: 2 columns */
|
|
|
|
|
.auth-options {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
}
|
|
|
|
|
@media (max-width: 700px) {
|
|
|
|
|
.auth-options { grid-template-columns: 1fr; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.auth-option {
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
background: var(--color-bg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Params + impacts: 2 columns */
|
|
|
|
|
.params-impacts-layout {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
}
|
|
|
|
|
@media (max-width: 700px) {
|
|
|
|
|
.params-impacts-layout { grid-template-columns: 1fr; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.param-grid {
|
|
|
|
|
@@ -814,6 +934,24 @@ function renderMarkdown(md: string): string {
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Vote button */
|
|
|
|
|
.btn-lg {
|
|
|
|
|
padding: 0.75rem 2rem;
|
|
|
|
|
font-size: 1.1rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Countdown */
|
|
|
|
|
.countdown {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
}
|
|
|
|
|
.countdown-unit strong {
|
|
|
|
|
font-size: 2rem;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.cms-body { line-height: 1.7; font-size: 0.9rem; }
|
|
|
|
|
.cms-body :deep(h2) { font-size: 1.2rem; margin: 0.75rem 0 0.5rem; }
|
|
|
|
|
.cms-body :deep(h3) { font-size: 1.05rem; margin: 0.5rem 0 0.25rem; }
|
|
|
|
|
|