Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/radar-y-min-max.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'dsfr-data': minor
---

`<dsfr-data-chart type="radar">` : 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 `<radar-chart>` (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.
9 changes: 5 additions & 4 deletions apps/builder-ia/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1135,8 +1135,8 @@
| 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 |
Expand All @@ -1151,7 +1151,7 @@
| 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 |
Expand Down Expand Up @@ -1521,6 +1521,7 @@

### <radar-chart>
- 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)

### <map-chart> (cartes choroplethes — API unifiee DSFR Chart 2.1)
- level : decoupage — "dep" (défaut), "reg", "aca", "monde"
Expand Down Expand Up @@ -1930,7 +1931,7 @@
### 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
Expand Down Expand Up @@ -2728,7 +2729,7 @@
|----------|------|--------|--------|-------------|
| sources | String | \`""\` | oui | Ids des sources cibles, separes par des espaces |
| url-sync | Boolean | \`false\` | non | Serialisation URL des filtres (#231, opt-in) : lecture au chargement (pre-remplit les UI), ecriture replaceState, parametres voisins preserves |
| url-param-map | String | \`""\` | non | Renommage des parametres URL : \`"param:field \| param2:field2"\` |

Check warning on line 2732 in apps/builder-ia/src/skills.ts

View workflow job for this annotation

GitHub Actions / quality

Unnecessary escape character: \|

### Pattern

Expand Down
67 changes: 67 additions & 0 deletions packages/core/src/components/dsfr-data-chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string>();

Expand All @@ -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<string, unknown>) {
Expand All @@ -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
Expand Down Expand Up @@ -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 <radar-chart> (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';
Expand Down Expand Up @@ -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). */
Expand Down
105 changes: 105 additions & 0 deletions packages/core/src/utils/chart-radial-scale.ts
Original file line number Diff line number Diff line change
@@ -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
* `<radar-chart>` (`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<string, RadialScaleOptionsLike | undefined> };
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;
}
8 changes: 6 additions & 2 deletions specs/components/dsfr-data-chart.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ <h2>Attributs</h2>
<tr><td><code>name</code></td><td>String</td><td>Noms des séries (ex: <code>'["Série 1", "Série 2"]'</code>)</td></tr>
<tr><td><code>unit-tooltip-bar</code></td><td>String</td><td>Unite des barres dans les tooltips (bar-line uniquement)</td></tr>
<tr><td><code>x-min</code> / <code>x-max</code></td><td>String</td><td>Bornes de l'axe X</td></tr>
<tr><td><code>y-min</code> / <code>y-max</code></td><td>String</td><td>Bornes de l'axe Y</td></tr>
<tr><td><code>y-min</code> / <code>y-max</code></td><td>String</td><td>Bornes de l'axe Y (line, bar, scatter, bar-line). Pour <code>type="radar"</code> : bornes de l'échelle radiale — le centre est fixé à <code>y-min</code> au lieu du minimum des données ; anneaux de grille entiers si les deux bornes sont entières avec une amplitude de 1 à 10</td></tr>
<tr><td><code>highlight-index</code></td><td>String</td><td>Indices a mettre en avant : <code>"[0, 2]"</code></td></tr>
<tr><td><code>reference-lines</code></td><td>String (JSON)</td><td>Lignes de référence (overlay) — graphiques cartésiens uniquement (line, bar, bar-line, scatter).
Chaque item : <code>{ axis: "x"|"y", value, label?, color?, dash?, position? }</code>.
Expand Down Expand Up @@ -253,13 +253,17 @@ <h3>5. Graphique radar</h3>
type="radar"
label-field="ministere"
value-field="score_rgaa"
y-min="0"
y-max="100"
selected-palette="default">
</dsfr-data-chart>
<div class="code-block"><pre>&lt;dsfr-data-chart
source="sites"
type="radar"
label-field="ministere"
value-field="score_rgaa"&gt;
value-field="score_rgaa"
y-min="0"
y-max="100"&gt;
&lt;/dsfr-data-chart&gt;</pre></div>
</section>

Expand Down
59 changes: 59 additions & 0 deletions tests/dsfr-data-chart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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';
Expand Down
Loading
Loading