Fix critical bugs + add zoom/overlay for citizen chart

Bugs fixed:
- Auth middleware now works on page refresh (plugin restores
  auth from localStorage before middleware runs)
- Bezier drag no longer snaps back: removed client-side p0
  recalculation during drag, only server computes p0 on mouseUp
- Removed redundant /login.vue page (homepage already has links)

New features:
- Interactive zoom on Bezier chart (buttons + mouse wheel +
  tier 1/tier 2 presets)
- Toggle to display outlier vote curves (public overlay endpoint)
- Tier 1 curve visually emphasized (thicker stroke)
- Dev credentials file at data/DEV-CREDENTIALS.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-21 15:50:37 +01:00
parent 39b2d7c9fd
commit 1365f4c86c
7 changed files with 182 additions and 109 deletions

View File

@@ -3,11 +3,3 @@
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
const authStore = useAuthStore()
onMounted(() => {
authStore.restore()
})
</script>

View File

@@ -6,6 +6,6 @@ export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated || !authStore.isAdmin) {
return navigateTo('/login')
return navigateTo('/')
}
})

View File

@@ -23,21 +23,38 @@
<!--
GRAPH 1: Interactive Bezier curve Prix au m3
(= dernier graph de eau.py NewModel bottom subplot)
-->
<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;">
<h3>Tarification progressive Prix au m<sup>3</sup></h3>
<span v-if="curveData.has_votes" class="badge badge-green">
Mediane de {{ curveData.vote_count }} vote(s)
</span>
<span v-else class="badge badge-amber">Courbe par defaut</span>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<label class="toggle-label">
<input type="checkbox" v-model="showOutliers" />
Courbes des votes
</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>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 1rem;">
Deplacez les poignees pour ajuster la forme de la courbe.
Le prix d'inflexion p<sub>0</sub> s'ajuste automatiquement pour equilibrer les recettes.
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">
Deplacez les poignees pour ajuster la courbe.
p<sub>0</sub> s'equilibre automatiquement.
</p>
<!-- 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="zoomIn">+</button>
<button class="btn btn-secondary btn-xs" @click="zoomOut">-</button>
<button class="btn btn-secondary btn-xs" @click="zoomReset">Reset</button>
<span style="font-size: 0.7rem; color: var(--color-text-muted);">
{{ zoomVolMin.toFixed(0) }}-{{ zoomVolMax.toFixed(0) }} m3 / {{ zoomPriceMin.toFixed(1) }}-{{ zoomPriceMax.toFixed(1) }} EUR
</span>
</div>
<div class="editor-layout">
<!-- SVG: Prix au m3 vs Volume (the Bezier curve) -->
<div class="chart-container">
@@ -50,34 +67,40 @@
@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(pmax)"
: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(0)" :y1="cy(p)" :x2="cx(vmax)" :y2="cy(p)"
:x1="cx(zoomVolMin)" :y1="cy(p)" :x2="cx(zoomVolMax)" :y2="cy(p)"
stroke="#e2e8f0" stroke-width="0.5" />
<!-- Volume labels -->
<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>
<!-- Price labels -->
<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 }}
{{ p.toFixed(p < 1 ? 1 : 0) }}
</text>
<!-- Axes labels -->
<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">
Prix/m3
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" />
@@ -88,19 +111,19 @@
<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) -->
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="2.5" />
<!-- 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.5" />
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2" />
<!-- Inflection reference lines -->
<line :x1="cx(bp.vinf)" :y1="cy(0)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
<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(0)" :y1="cy(localP0)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
<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(0) + 30" :y="cy(localP0) - 6" font-size="12" fill="#1e293b" font-weight="600">
<text :x="cx(zoomVolMin) + 30" :y="cy(localP0) - 6" font-size="12" fill="#1e293b" font-weight="600">
p0 = {{ localP0.toFixed(2) }} EUR/m3
</text>
@@ -113,7 +136,6 @@
@mousedown.prevent="startDrag(key)"
@touchstart.prevent="startDrag(key)"
/>
<!-- Point labels -->
<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">
@@ -345,28 +367,79 @@ const isCitizenAuth = computed(() => authStore.isCitizen && authStore.communeSlu
const submitting = ref(false)
const voteSuccess = ref(false)
// ── Chart 1: Interactive Bezier ──
// ── Chart 1: Interactive Bezier with zoom ──
const W = 620
const H = 380
const margin = { top: 20, right: 20, bottom: 28, left: 45 }
const plotW = W - margin.left - margin.right
const plotH = H - margin.top - margin.bottom
function cx(v: number) { return margin.left + (v / vmax.value) * plotW }
function cy(p: number) { return margin.top + plotH - (p / pmax.value) * plotH }
function fromX(sx: number) { return ((sx - margin.left) / plotW) * vmax.value }
function fromY(sy: number) { return ((margin.top + plotH - sy) / plotH) * pmax.value }
// Zoom state (data-space ranges)
const zoomVolMin = ref(0)
const zoomVolMax = ref(2100)
const zoomPriceMin = ref(0)
const zoomPriceMax = ref(20)
// Outlier overlay
const showOutliers = ref(false)
const outlierVotes = ref<any[]>([])
// Zoom-aware coordinate transforms
function cx(v: number) { return margin.left + ((v - zoomVolMin.value) / (zoomVolMax.value - zoomVolMin.value)) * plotW }
function cy(p: number) { return margin.top + plotH - ((p - zoomPriceMin.value) / (zoomPriceMax.value - zoomPriceMin.value)) * plotH }
function fromX(sx: number) { return zoomVolMin.value + ((sx - margin.left) / 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(localP0.value * 1.5, 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) {
if (e.deltaY < 0) zoomIn()
else zoomOut()
}
const gridVols = computed(() => {
const step = Math.ceil(vmax.value / 7 / 100) * 100
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[] = []
for (let v = step; v < vmax.value; v += step) arr.push(v)
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 step = Math.ceil(pmax.value / 5)
const range = zoomPriceMax.value - zoomPriceMin.value
const step = range > 5 ? Math.ceil(range / 5) : range > 1 ? 1 : 0.5
const arr: number[] = []
for (let p = step; p <= pmax.value; p += step) arr.push(p)
const start = Math.ceil(zoomPriceMin.value / step) * step
for (let p = start; p <= zoomPriceMax.value; p += step) arr.push(p)
return arr
})
@@ -399,6 +472,16 @@ const tier2Path = computed(() => {
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)}`
})
// Outlier vote paths
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)
@@ -417,7 +500,7 @@ function onTouchMove(e: TouchEvent) { if (dragging.value && e.touches[0]) handle
function onMouseUp() {
if (dragging.value) {
dragging.value = null
debouncedServerCompute()
serverCompute()
}
}
@@ -451,29 +534,12 @@ function handleDrag(pt: { x: number; y: number }) {
break
}
}
recalculate()
}
function recalculate() {
if (!households.value.length) return
localP0.value = computeP0(
households.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(
households.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,
}))
// 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
function debouncedServerCompute() {
function serverCompute() {
if (serverTimeout) clearTimeout(serverTimeout)
serverTimeout = setTimeout(async () => {
try {
@@ -489,7 +555,7 @@ function debouncedServerCompute() {
new_price_rs: imp.new_price_rs,
}))
} catch {}
}, 300)
}, 150)
}
// ── Chart 2: Baseline linear model ──
@@ -593,12 +659,14 @@ onMounted(async () => {
params.value = p
curveData.value = curve
// Set tariff params
// Set tariff params + zoom
vmax.value = p.vmax
pmax.value = p.pmax
recettes.value = p.recettes
abop.value = p.abop
abos.value = p.abos
zoomVolMax.value = p.vmax
zoomPriceMax.value = p.pmax
// Set initial Bezier params from median (or default)
if (curve.median) {
@@ -622,6 +690,11 @@ onMounted(async () => {
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
// Load all vote curves for outlier overlay (public endpoint needed)
try {
outlierVotes.value = await api.get<any[]>(`/communes/${slug}/votes/current/overlay`)
} catch { /* endpoint may not exist yet */ }
} catch (e: any) {
loadError.value = e.message
} finally {
@@ -648,6 +721,31 @@ function renderMarkdown(md: string): string {
</script>
<style scoped>
.zoom-bar {
display: flex;
gap: 0.25rem;
align-items: center;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.btn-xs {
padding: 0.15rem 0.5rem;
font-size: 0.7rem;
border-radius: 4px;
}
.toggle-label {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--color-text-muted);
cursor: pointer;
}
.toggle-label input { cursor: pointer; }
.editor-layout {
display: grid;
grid-template-columns: 1fr 320px;

View File

@@ -1,46 +0,0 @@
<template>
<div style="max-width: 500px; margin: 2rem auto;">
<div class="page-header" style="text-align: center;">
<h1>Connexion</h1>
<p style="color: var(--color-text-muted);">Choisissez votre espace.</p>
</div>
<div class="grid grid-2">
<NuxtLink to="/login/commune" class="card login-choice">
<h3>Commune</h3>
<p>Gérer les données et la tarification de votre commune.</p>
</NuxtLink>
<NuxtLink to="/login/admin" class="card login-choice">
<h3>Super Admin</h3>
<p>Gestion globale des communes et administrateurs.</p>
</NuxtLink>
</div>
<p style="text-align: center; margin-top: 1.5rem;">
<NuxtLink to="/">&larr; Retour à l'accueil</NuxtLink>
</p>
</div>
</template>
<style scoped>
.login-choice {
text-align: center;
cursor: pointer;
transition: box-shadow 0.15s;
}
.login-choice:hover {
box-shadow: var(--shadow-md);
text-decoration: none;
}
.login-choice h3 {
color: var(--color-primary);
margin-bottom: 0.5rem;
}
.login-choice p {
font-size: 0.8rem;
color: var(--color-text-muted);
}
</style>

View File

@@ -0,0 +1,8 @@
/**
* Client-only plugin: restores auth state from localStorage
* BEFORE route middleware runs.
*/
export default defineNuxtPlugin(() => {
const authStore = useAuthStore()
authStore.restore()
})