diff --git a/.changeset/radar-y-min-max.md b/.changeset/radar-y-min-max.md new file mode 100644 index 0000000..49b9d1a --- /dev/null +++ b/.changeset/radar-y-min-max.md @@ -0,0 +1,5 @@ +--- +'dsfr-data': minor +--- + +`` : support des attributs `y-min` / `y-max` pour borner l'échelle radiale (issue maturity-model#9). Sans borne, `scales.r` de Chart.js s'auto-ajuste au min/max des données — le minimum se retrouve au centre du radar, ce qui est trompeur. Les bornes sont relayées à l'API upstream `scale-min`/`scale-max` de `` (suggestedMin/Max, baseline déclarative), puis affinées post-montage sur l'instance Chart.js : bornes dures `scales.r.min`/`max`, et `ticks.stepSize: 1` (anneaux de grille entiers) quand les deux bornes sont entières avec une amplitude de 1 à 10. Comportement inchangé sans `y-min`/`y-max` et pour les autres types. diff --git a/apps/builder-ia/src/skills.ts b/apps/builder-ia/src/skills.ts index dc71929..4170e3a 100644 --- a/apps/builder-ia/src/skills.ts +++ b/apps/builder-ia/src/skills.ts @@ -1135,8 +1135,8 @@ ce tableau en format DSFR Chart (tableaux imbriques x/y). | highlight-index | String | \`""\` | non | Indices a mettre en avant : \`"[0, 2]"\` | | x-min | String | \`""\` | non | Limite min axe X | | x-max | String | \`""\` | non | Limite max axe X | -| y-min | String | \`""\` | non | Limite min axe Y | -| y-max | String | \`""\` | non | Limite max axe Y | +| y-min | String | \`""\` | non | Limite min axe Y. Pour type radar : borne min de l'echelle radiale (le centre du radar est fixe a y-min au lieu du minimum des donnees) | +| y-max | String | \`""\` | non | Limite max axe Y. Pour type radar : borne max de l'echelle radiale ; si y-min et y-max sont entiers avec une amplitude de 1 a 10, anneaux de grille entiers (stepSize 1) | | gauge-value | Number | \`null\` | type gauge | Valeur de la jauge (0-100) | | code-field | String | \`""\` | types map* | Champ contenant le code : departement/region (map, map-reg), nom d'academie en majuscules (map-aca), code pays ISO 3166-1 alpha-2/alpha-3/numerique (map-monde, converti en alpha-2) — prioritaire sur label-field | | map-highlight | String | \`""\` | non | Departements/regions a surligner | @@ -1151,7 +1151,7 @@ ce tableau en format DSFR Chart (tableaux imbriques x/y). | bar | source, type, label-field, value-field | horizontal, stacked, highlight-index, selected-palette | | line | source, type, label-field, value-field | x-min, x-max, y-min, y-max, value-field-2 | | pie | source, type, label-field, value-field | fill (false=anneau, true=camembert plein) | -| radar | source, type, label-field, value-field | value-field-2, name | +| radar | source, type, label-field, value-field | value-field-2, name, y-min, y-max | | scatter | source, type, label-field, value-field | x-min, x-max, y-min, y-max | | gauge | source, type, gauge-value | - | | bar-line | source, type, label-field, value-field, value-field-2 | name, unit-tooltip, unit-tooltip-bar | @@ -1521,6 +1521,7 @@ name='["Série A","Série B"]' ### - Multi-séries pour comparer des profils +- scale-min, scale-max : bornes de l'echelle radiale (via y-min/y-max de dsfr-data-chart) ### (cartes choroplethes — API unifiee DSFR Chart 2.1) - level : decoupage — "dep" (défaut), "reg", "aca", "monde" @@ -1930,7 +1931,7 @@ Guide pour choisir le type de visualisation adapte aux données. ### Radar - **Quand** : profils multicriteres, comparaison de dimensions - **Champs** : label-field (criteres), value-field (scores) -- **Supporte** : value-field-2 ou value-fields pour comparer plusieurs profils +- **Supporte** : value-field-2 ou value-fields pour comparer plusieurs profils, y-min/y-max pour fixer l'echelle radiale (recommande : sans bornes, le centre du radar = minimum des donnees, ce qui est trompeur) ### Nuage de points (scatter) - **Quand** : correlation entre deux variables numériques diff --git a/packages/core/src/components/dsfr-data-chart.ts b/packages/core/src/components/dsfr-data-chart.ts index 54b279e..0e133c4 100644 --- a/packages/core/src/components/dsfr-data-chart.ts +++ b/packages/core/src/components/dsfr-data-chart.ts @@ -30,6 +30,13 @@ import { type TargetsLayout, type TargetMarkerGeometry, } from '../utils/chart-targets.js'; +import { + computeRadialScaleBounds, + applyRadialScaleBounds, + isRadialChartType, + type RadialScaleBounds, + type RadialChartLike, +} from '../utils/chart-radial-scale.js'; import { escapeHtml, toNumber, isValidDeptCode } from '@dsfr-data/shared/lib'; import { toIsoA2 } from '../data/continent-lookup.js'; @@ -291,6 +298,9 @@ export class DsfrDataChart extends SourceSubscriberMixin(LitElement) { /** Tooltip cible en cours d'affichage (#377) */ private _targetTooltipEl: HTMLDivElement | null = null; + /** Bornes radar (y-min/y-max → scales.r) : poll rAF en cours */ + private _radialBoundsRaf: number | null = null; + /** Attributs poses par la mise a jour incrementale (#305) */ private _managedChartAttrs = new Set(); @@ -301,6 +311,7 @@ export class DsfrDataChart extends SourceSubscriberMixin(LitElement) { for (const t of this._pendingTimers) clearTimeout(t); this._pendingTimers.clear(); this._cleanupChartOverlays(); + this._cancelRadialBoundsRaf(); } updated(changed: Map) { @@ -309,6 +320,9 @@ export class DsfrDataChart extends SourceSubscriberMixin(LitElement) { // chaque rendu. Chart.js rend de maniere asynchrone → le draw poll en rAF // jusqu'a ce que l'instance soit prete. this._refreshChartOverlays(); + // Bornes dures de l'echelle radiale (radar + y-min/y-max) : meme principe, + // ré-appliquées après chaque rendu (le watcher Vue $props recrée le chart). + this._refreshRadialScaleBounds(); } // Light DOM pour les styles DSFR @@ -741,6 +755,15 @@ export class DsfrDataChart extends SourceSubscriberMixin(LitElement) { } } + if (this.type === 'radar') { + // Échelle radiale (issue maturity-model#9) : relaie y-min/y-max vers + // l'API upstream scale-min/scale-max de (suggestedMin/Max) + // — baseline déclarative qui survit aux recréations du chart par le + // watcher Vue $props. Les bornes DURES scales.r.min/max + stepSize sont + // affinées post-montage par _refreshRadialScaleBounds(). + if (this.yMin) attrs['scale-min'] = this.yMin; + if (this.yMax) attrs['scale-max'] = this.yMax; + } if (this.type === 'bar') { if (this.horizontal) attrs['horizontal'] = 'true'; if (this.stacked) attrs['stacked'] = 'true'; @@ -970,6 +993,50 @@ export class DsfrDataChart extends SourceSubscriberMixin(LitElement) { this._removeChartOverlays(); } + // --- Bornes de l'echelle radiale (radar + y-min/y-max, maturity-model#9) ---- + // Sans borne, scales.r s'auto-ajuste au min/max des donnees (le minimum se + // retrouve au CENTRE du radar). L'upstream n'expose que suggestedMin/Max + // (bornes molles) : on pose les bornes DURES min/max + stepSize directement + // sur l'instance Chart.js, avec le meme rAF-poll que les overlays. + + private _cancelRadialBoundsRaf() { + if (this._radialBoundsRaf !== null) { + cancelAnimationFrame(this._radialBoundsRaf); + this._radialBoundsRaf = null; + } + } + + /** (Re)programme l'application des bornes radiales apres chaque rendu. */ + private _refreshRadialScaleBounds() { + this._cancelRadialBoundsRaf(); + if (!isRadialChartType(this.type)) return; + const bounds = computeRadialScaleBounds(this.yMin, this.yMax); + if (!bounds) return; + this._scheduleRadialBoundsApply(bounds, 120); + } + + /** Poll rAF jusqu'a ce que l'instance Chart.js radar soit prete. */ + private _scheduleRadialBoundsApply(bounds: RadialScaleBounds, framesLeft: number) { + if (typeof requestAnimationFrame === 'undefined') return; + this._radialBoundsRaf = requestAnimationFrame(() => { + this._radialBoundsRaf = null; + if (!this.isConnected) return; + if (this._applyRadialScaleBounds(bounds)) return; + if (framesLeft > 0) this._scheduleRadialBoundsApply(bounds, framesLeft - 1); + // Degradation gracieuse sans warn : la baseline declarative + // scale-min/scale-max reste appliquee par l'upstream. + }); + } + + /** Applique les bornes sur l'instance. Retourne false si pas prete. */ + private _applyRadialScaleBounds(bounds: RadialScaleBounds): boolean { + const hosts = this._resolveOverlayHosts(); + if (!hosts) return false; + const chart = resolveChartInstance(hosts.chartEl, hosts.canvas); + if (!chart || !chart.chartArea || chart.chartArea.width <= 0) return false; + return applyRadialScaleBounds(chart as RadialChartLike, bounds); + } + // --- Cibles : interactivite (tooltip groupe par echeance, legende, #377) ---- /** Branche le tooltip sur les losanges (seuls elements pointer-events:auto). */ diff --git a/packages/core/src/utils/chart-radial-scale.ts b/packages/core/src/utils/chart-radial-scale.ts new file mode 100644 index 0000000..84f2dc8 --- /dev/null +++ b/packages/core/src/utils/chart-radial-scale.ts @@ -0,0 +1,105 @@ +/** + * Bornes de l'échelle radiale pour dsfr-data-chart type="radar" — logique PURE + * et testable (issue maturity-model#9). + * + * Sans borne, l'échelle radiale de Chart.js (`scales.r`) s'auto-ajuste au + * min/max des données : le minimum se retrouve au CENTRE du radar, ce qui est + * trompeur (un score 2,1/4 semble nul). Quand `y-min` / `y-max` sont fournis : + * + * 1. baseline déclarative : les props upstream `scale-min` / `scale-max` de + * `` (`suggestedMin` / `suggestedMax`) — survivent aux + * recréations du chart par le watcher Vue `$props` ; + * 2. affinage post-montage sur l'instance Chart.js (via + * {@link resolveChartInstance}) : bornes DURES `scales.r.min` / `max`, et + * `ticks.stepSize: 1` si les deux bornes sont entières avec une amplitude + * de 1 à 10 (anneaux de grille entiers). + * + * Aucune dépendance Lit / DOM-bridge : réutilisable et testable hors composant. + */ + +/** Bornes calculées à appliquer à l'échelle radiale `scales.r`. */ +export interface RadialScaleBounds { + min?: number; + max?: number; + /** Pas des anneaux de grille (posé seulement si bornes entières, amplitude 1-10). */ + stepSize?: number; +} + +/** Les bornes radiales ne valent que pour le type radar. */ +export function isRadialChartType(type: string): boolean { + return type === 'radar'; +} + +function parseBound(raw: string): number | null { + const trimmed = (raw || '').trim(); + if (!trimmed) return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; +} + +/** + * Calcule les bornes radiales depuis les attributs `y-min` / `y-max` (strings). + * - aucune borne valide → `null` (rien à appliquer, comportement inchangé) + * - `stepSize: 1` seulement si les DEUX bornes sont entières et `0 < max - min <= 10` + */ +export function computeRadialScaleBounds(yMin: string, yMax: string): RadialScaleBounds | null { + const min = parseBound(yMin); + const max = parseBound(yMax); + if (min === null && max === null) return null; + + const bounds: RadialScaleBounds = {}; + if (min !== null) bounds.min = min; + if (max !== null) bounds.max = max; + if ( + min !== null && + max !== null && + Number.isInteger(min) && + Number.isInteger(max) && + max - min > 0 && + max - min <= 10 + ) { + bounds.stepSize = 1; + } + return bounds; +} + +// --- Application sur l'instance Chart.js (duck-typing) ------------------------ + +export interface RadialScaleOptionsLike { + min?: number; + max?: number; + ticks?: { stepSize?: number }; +} +export interface RadialChartLike { + options?: { scales?: Record }; + update?: (mode?: string) => void; +} + +/** + * Pose les bornes sur `options.scales.r` de l'instance Chart.js et redessine + * (`update("none")`) si quelque chose a changé. Retourne `false` si l'échelle + * radiale est introuvable (instance pas prête → re-tenter), `true` sinon. + */ +export function applyRadialScaleBounds(chart: RadialChartLike, bounds: RadialScaleBounds): boolean { + const r = chart.options?.scales?.r; + if (!r) return false; + + let changed = false; + if (bounds.min !== undefined && r.min !== bounds.min) { + r.min = bounds.min; + changed = true; + } + if (bounds.max !== undefined && r.max !== bounds.max) { + r.max = bounds.max; + changed = true; + } + if (bounds.stepSize !== undefined) { + if (!r.ticks) r.ticks = {}; + if (r.ticks.stepSize !== bounds.stepSize) { + r.ticks.stepSize = bounds.stepSize; + changed = true; + } + } + if (changed) chart.update?.('none'); + return true; +} diff --git a/specs/components/dsfr-data-chart.html b/specs/components/dsfr-data-chart.html index fab4d2d..22ff45e 100644 --- a/specs/components/dsfr-data-chart.html +++ b/specs/components/dsfr-data-chart.html @@ -57,7 +57,7 @@

Attributs

nameStringNoms des séries (ex: '["Série 1", "Série 2"]') unit-tooltip-barStringUnite des barres dans les tooltips (bar-line uniquement) x-min / x-maxStringBornes de l'axe X - y-min / y-maxStringBornes de l'axe Y + y-min / y-maxStringBornes de l'axe Y (line, bar, scatter, bar-line). Pour type="radar" : bornes de l'échelle radiale — le centre est fixé à y-min au lieu du minimum des données ; anneaux de grille entiers si les deux bornes sont entières avec une amplitude de 1 à 10 highlight-indexStringIndices a mettre en avant : "[0, 2]" reference-linesString (JSON)Lignes de référence (overlay) — graphiques cartésiens uniquement (line, bar, bar-line, scatter). Chaque item : { axis: "x"|"y", value, label?, color?, dash?, position? }. @@ -253,13 +253,17 @@

5. Graphique radar

type="radar" label-field="ministere" value-field="score_rgaa" + y-min="0" + y-max="100" selected-palette="default">
<dsfr-data-chart
   source="sites"
   type="radar"
   label-field="ministere"
-  value-field="score_rgaa">
+  value-field="score_rgaa"
+  y-min="0"
+  y-max="100">
 </dsfr-data-chart>
diff --git a/tests/dsfr-data-chart.test.ts b/tests/dsfr-data-chart.test.ts index 90d26d5..99b9eb0 100644 --- a/tests/dsfr-data-chart.test.ts +++ b/tests/dsfr-data-chart.test.ts @@ -240,6 +240,32 @@ describe('DsfrDataChart', () => { }); }); + describe('_refreshRadialScaleBounds', () => { + it('schedules a rAF poll for radar with bounds', () => { + chart.type = 'radar'; + chart.yMin = '0'; + chart.yMax = '4'; + (chart as any)._refreshRadialScaleBounds(); + expect((chart as any)._radialBoundsRaf).not.toBeNull(); + (chart as any)._cancelRadialBoundsRaf(); + expect((chart as any)._radialBoundsRaf).toBeNull(); + }); + + it('does nothing for radar without bounds', () => { + chart.type = 'radar'; + (chart as any)._refreshRadialScaleBounds(); + expect((chart as any)._radialBoundsRaf).toBeNull(); + }); + + it('does nothing for non-radar types even with bounds', () => { + chart.type = 'line'; + chart.yMin = '0'; + chart.yMax = '4'; + (chart as any)._refreshRadialScaleBounds(); + expect((chart as any)._radialBoundsRaf).toBeNull(); + }); + }); + describe('_getTypeSpecificAttributes', () => { beforeEach(() => { (chart as any)._data = [ @@ -284,6 +310,39 @@ describe('DsfrDataChart', () => { expect(JSON.parse(attrs['name'])).toEqual(['A', 'B']); }); + it('radar maps y-min/y-max to upstream scale-min/scale-max', () => { + chart.type = 'radar'; + chart.yMin = '0'; + chart.yMax = '4'; + const { attrs } = (chart as any)._getTypeSpecificAttributes(); + expect(attrs['scale-min']).toBe('0'); + expect(attrs['scale-max']).toBe('4'); + }); + + it('radar without y-min/y-max sets no scale bounds (unchanged behavior)', () => { + chart.type = 'radar'; + const { attrs } = (chart as any)._getTypeSpecificAttributes(); + expect(attrs['scale-min']).toBeUndefined(); + expect(attrs['scale-max']).toBeUndefined(); + }); + + it('radar with a single bound sets only that bound', () => { + chart.type = 'radar'; + chart.yMax = '4'; + const { attrs } = (chart as any)._getTypeSpecificAttributes(); + expect(attrs['scale-min']).toBeUndefined(); + expect(attrs['scale-max']).toBe('4'); + }); + + it('non-radar types do not get scale-min/scale-max', () => { + chart.type = 'line'; + chart.yMin = '0'; + chart.yMax = '4'; + const { attrs } = (chart as any)._getTypeSpecificAttributes(); + expect(attrs['scale-min']).toBeUndefined(); + expect(attrs['scale-max']).toBeUndefined(); + }); + it('returns bar-line specific attributes', () => { (chart as any)._data = [{ cat: 'A', v1: 10, v2: 100 }]; chart.type = 'bar-line'; diff --git a/tests/utils/chart-radial-scale.test.ts b/tests/utils/chart-radial-scale.test.ts new file mode 100644 index 0000000..851bdca --- /dev/null +++ b/tests/utils/chart-radial-scale.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + computeRadialScaleBounds, + applyRadialScaleBounds, + isRadialChartType, + type RadialChartLike, +} from '@/utils/chart-radial-scale.js'; + +// --- Chart.js mock (duck-typed) ---------------------------------------------- + +function mockRadarChart(overrides: Partial = {}): RadialChartLike & { + update: ReturnType; +} { + return { + options: { scales: { r: {} } }, + update: vi.fn(), + ...overrides, + } as RadialChartLike & { update: ReturnType }; +} + +describe('chart-radial-scale', () => { + describe('isRadialChartType', () => { + it('accepte radar uniquement', () => { + expect(isRadialChartType('radar')).toBe(true); + expect(isRadialChartType('line')).toBe(false); + expect(isRadialChartType('bar')).toBe(false); + expect(isRadialChartType('pie')).toBe(false); + expect(isRadialChartType('')).toBe(false); + }); + }); + + describe('computeRadialScaleBounds', () => { + it('retourne null sans aucune borne', () => { + expect(computeRadialScaleBounds('', '')).toBeNull(); + expect(computeRadialScaleBounds(' ', '')).toBeNull(); + }); + + it('retourne null pour des valeurs non numériques', () => { + expect(computeRadialScaleBounds('abc', 'xyz')).toBeNull(); + }); + + it('calcule min et max entiers avec stepSize 1 (amplitude <= 10)', () => { + expect(computeRadialScaleBounds('0', '4')).toEqual({ min: 0, max: 4, stepSize: 1 }); + expect(computeRadialScaleBounds('0', '10')).toEqual({ min: 0, max: 10, stepSize: 1 }); + expect(computeRadialScaleBounds('-5', '5')).toEqual({ min: -5, max: 5, stepSize: 1 }); + }); + + it('pas de stepSize si amplitude > 10', () => { + expect(computeRadialScaleBounds('0', '100')).toEqual({ min: 0, max: 100 }); + }); + + it('pas de stepSize pour des bornes non entières', () => { + expect(computeRadialScaleBounds('0.5', '4')).toEqual({ min: 0.5, max: 4 }); + expect(computeRadialScaleBounds('0', '4.5')).toEqual({ min: 0, max: 4.5 }); + }); + + it('pas de stepSize avec une seule borne', () => { + expect(computeRadialScaleBounds('0', '')).toEqual({ min: 0 }); + expect(computeRadialScaleBounds('', '4')).toEqual({ max: 4 }); + }); + + it('pas de stepSize si amplitude nulle ou négative', () => { + expect(computeRadialScaleBounds('4', '4')).toEqual({ min: 4, max: 4 }); + expect(computeRadialScaleBounds('5', '2')).toEqual({ min: 5, max: 2 }); + }); + + it('borne partiellement invalide : seule la valide est conservée', () => { + expect(computeRadialScaleBounds('abc', '4')).toEqual({ max: 4 }); + }); + }); + + describe('applyRadialScaleBounds', () => { + it('pose min, max et stepSize sur scales.r puis update("none")', () => { + const chart = mockRadarChart(); + const ok = applyRadialScaleBounds(chart, { min: 0, max: 4, stepSize: 1 }); + expect(ok).toBe(true); + expect(chart.options!.scales!.r).toEqual({ min: 0, max: 4, ticks: { stepSize: 1 } }); + expect(chart.update).toHaveBeenCalledWith('none'); + }); + + it('pose seulement les bornes fournies (pas de stepSize)', () => { + const chart = mockRadarChart(); + applyRadialScaleBounds(chart, { max: 4.5 }); + expect(chart.options!.scales!.r).toEqual({ max: 4.5 }); + expect(chart.update).toHaveBeenCalledWith('none'); + }); + + it('préserve les ticks existants (display: false upstream)', () => { + const chart = mockRadarChart({ + options: { scales: { r: { ticks: { display: false } as never } } }, + }); + applyRadialScaleBounds(chart, { min: 0, max: 4, stepSize: 1 }); + expect(chart.options!.scales!.r).toEqual({ + min: 0, + max: 4, + ticks: { display: false, stepSize: 1 }, + }); + }); + + it('idempotent : pas de update() si rien ne change', () => { + const chart = mockRadarChart({ + options: { scales: { r: { min: 0, max: 4, ticks: { stepSize: 1 } } } }, + }); + const ok = applyRadialScaleBounds(chart, { min: 0, max: 4, stepSize: 1 }); + expect(ok).toBe(true); + expect(chart.update).not.toHaveBeenCalled(); + }); + + it('retourne false si scales.r est introuvable (instance pas prête)', () => { + expect(applyRadialScaleBounds({}, { min: 0 })).toBe(false); + expect(applyRadialScaleBounds({ options: {} }, { min: 0 })).toBe(false); + expect(applyRadialScaleBounds({ options: { scales: {} } }, { min: 0 })).toBe(false); + }); + }); +});