fix(chart): funnel y calendar-heatmap tambien rompian en RTL, y la regla no es "voltea el anchor"

Revisadas las demas graficas del catalogo tras el eje Y. Dos rotas, y arreglarlas
enseño donde estaba mi error de criterio.

FUNNEL. En RTL las etiquetas y sus lineas guia se dibujaban ENCIMA del embudo.
Su `labelPosition` ofrece `'inside' | 'right' | 'none'`: `'right'` es el UNICO
valor lateral —no existe `'left'`—, asi que ese nombre no es una eleccion fisica
del consumidor (a diferencia del `side` de drawer, que ofrece los dos) sino una
suposicion LTR. Bajo RTL la columna de etiquetas va al lado de lectura contrario,
asi que se espeja la composicion entera: embudo a la derecha, etiquetas a la
izquierda. El cuerpo del embudo es simetrico respecto a `cx`, de modo que
espejarlo es solo mover ese centro.

CALENDAR-HEATMAP. Sus etiquetas de mes y de dia viven en canalones FIJOS y sus
anchors `start`/`end` se volteaban, dejando "lun/mié/vie" pegadas a las celdas.
Ahi si hay que forzar el anchor fisico, porque el canalon no se mueve.

LA REGLA, que me costo dos intentos en el funnel: si la composicion SE ESPEJA, el
anchor LOGICO ya es el correcto y voltearlo ademas deshace el espejo —lo hice y
las etiquetas volvieron sobre el embudo—. Si la composicion NO se espeja (el
canalon del eje Y, los de este calendario), hay que forzar el fisico. Queda
escrito en rtl.svelte.ts, que centraliza las dos piezas: `physicalAnchor()` y un
`createChartRtl()` que lee la direccion RESUELTA del elemento —las graficas
sueltas no tienen el contexto de <Chart>, y `prefs.getDir()` devuelve undefined
en este arbol mientras el elemento computa rtl bien—.

Verificado MIRANDO capturas en oscuro, en RTL y en la pagina, antes y despues de
cada cambio, no midiendo en LTR como hice las tres primeras veces.

QUEDA, y no esta hecho: el calendario no se espeja —enero sigue a la izquierda en
RTL, igual que le pasaba al eje X del chart antes de 55070c688—. Y de la misma
clase sin revisar: heatmap.svelte:145 (`end` en el canalon izquierdo) y los
anchors calculados de radar-chart.

check 74 = linea base exacta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 6e8d2d9223
commit 2636b40eaf

@ -11,6 +11,7 @@
import './chart.css';
import { extent } from '$libs/plots';
import { ActiveEidos } from '$uix/eidos';
import { createChartRtl } from './rtl.svelte';
import { motionAttrs } from '$uix/eidos/lib/motion/motion-attrs';
import { seriesColor } from './context';
import type { CalendarHeatmapProps } from './types';
@ -30,6 +31,10 @@
}: CalendarHeatmapProps = $props();
const eidos = ActiveEidos.require();
// Month and weekday labels sit in fixed PHYSICAL gutters; their start/end
// anchors are logical and would flip under RTL, pushing the text onto the
// cells.
const rtl = createChartRtl(eidos);
// Active locale from the uix langs service (a reactive $state): anything reading
// it — labels, week start — re-evaluates when the toolbar changes the locale.
function activeLocale(): string | undefined {
@ -182,6 +187,7 @@
<figure data-chart-figure>
<svg
bind:this={rtl.ref}
data-chart
role="img"
aria-label={ariaLabel}
@ -208,10 +214,10 @@
/>
{/each}
{#each monthLabels as ml}
<text data-chart-axis-label x={ml.x} y={ml.y} text-anchor={ml.anchor} dominant-baseline="middle">{ml.label}</text>
<text data-chart-axis-label x={ml.x} y={ml.y} text-anchor={rtl.anchor(ml.anchor)} dominant-baseline="middle">{ml.label}</text>
{/each}
{#each weekdayLabels as wl}
<text data-chart-axis-label x={wl.x} y={wl.y} text-anchor={wl.anchor} dominant-baseline="middle">{wl.label}</text>
<text data-chart-axis-label x={wl.x} y={wl.y} text-anchor={rtl.anchor(wl.anchor)} dominant-baseline="middle">{wl.label}</text>
{/each}
</svg>
{#if tooltip && active !== null && cells[active] && !cells[active].empty}

@ -16,6 +16,7 @@
*/
import './chart.css';
import { ActiveEidos } from '$uix/eidos';
import { createChartRtl } from './rtl.svelte';
import { seriesColor, seriesContrast } from './context';
import type { FunnelProps } from './types';
@ -30,6 +31,10 @@
}: FunnelProps = $props();
const eidos = ActiveEidos.require();
// The side labels live in a gutter on the physical right; their `start`
// anchor is logical and would flip under RTL, dropping the whole label
// column on top of the funnel body.
const rtl = createChartRtl(eidos);
function fmt(v: number): string {
if (format) return format(v);
try {
@ -42,7 +47,20 @@
// When labels sit to the right, the trapezoids take the left ~54% and the
// labels the rest; otherwise they span the full width.
const funnelW = $derived(labelPosition === 'right' ? width * 0.54 : width);
const cx = $derived(funnelW / 2);
/**
* `labelPosition` offers only `'right'` as a side — there is no `'left'` — so
* that name encodes an LTR assumption rather than a physical choice by the
* consumer (unlike a drawer's `side`, which offers both). Under RTL the label
* column belongs on the reading-start side, so the whole composition mirrors:
* funnel to the right, labels to the left.
*
* The funnel body is symmetric about `cx`, so mirroring it is just moving that
* centre — the polygons need no other change.
*/
const mirrored = $derived(rtl.current && labelPosition === 'right');
const cx = $derived(mirrored ? width - funnelW / 2 : funnelW / 2);
/** Where the label gutter begins, measured from the funnel outwards. */
const gutterEdge = $derived(mirrored ? width - funnelW : funnelW);
const top = $derived(Math.max(1, data[0]?.value ?? 1));
const max = $derived(Math.max(1, ...data.map((d) => (d.value > 0 ? d.value : 0))));
const stages = $derived.by(() => {
@ -66,7 +84,8 @@
contrast: seriesContrast(value),
cy: (y0 + y1) / 2,
pct: Math.round((v / top) * 100),
rightEdge: cx + topW / 2
// The edge the leader line springs from: outer side of the funnel.
rightEdge: mirrored ? cx - topW / 2 : cx + topW / 2
};
});
});
@ -74,6 +93,7 @@
<figure data-chart-figure>
<svg
bind:this={rtl.ref}
data-chart
role="img"
aria-label={ariaLabel}
@ -105,10 +125,20 @@
data-chart-funnel-connector
x1={stage.rightEdge}
y1={stage.cy}
x2={funnelW + 16}
x2={mirrored ? gutterEdge - 16 : gutterEdge + 16}
y2={stage.cy}
/>
<text data-chart-funnel-side x={funnelW + 22} y={stage.cy} text-anchor="start" dominant-baseline="middle">
<!-- `text-anchor` stays LOGICAL here, unflipped: the gutter itself
mirrors, so `start` anchors on the funnel's side in both directions
and the text grows away from it. Flipping it as well undid the
mirroring and put the labels back on top of the funnel. -->
<text
data-chart-funnel-side
x={mirrored ? gutterEdge - 22 : gutterEdge + 22}
y={stage.cy}
text-anchor="start"
dominant-baseline="middle"
>
<tspan data-chart-funnel-name>{stage.label}</tspan>
<tspan data-chart-funnel-meta dx="8">{fmt(stage.value)} · {stage.pct}%</tspan>
</text>

@ -0,0 +1,69 @@
import type { ActiveEidos } from '$uix/eidos';
/**
* Direction helpers shared by the chart family.
*
* SVG's `text-anchor` is LOGICAL: under `direction: rtl`, `end` means the LEFT
* side. Chart gutters are not logical — the y-axis labels, a calendar's weekday
* column and a funnel's leader labels all sit on a fixed PHYSICAL side of the
* drawing. Left alone, every one of those anchors flips when the page does and
* the text grows back across the graphic: measured on the y axis, a label ran
* from x=32 to x=59.9 with the axis at 46, i.e. straight through it. The funnel
* put its whole label column on top of the funnel body.
*/
/** Flip a logical `text-anchor` so it lands on the intended PHYSICAL side. */
export function physicalAnchor(
anchor: 'start' | 'middle' | 'end',
rtl: boolean
): 'start' | 'middle' | 'end' {
if (!rtl || anchor === 'middle') return anchor;
return anchor === 'start' ? 'end' : 'start';
}
/**
* Reactive "is this element resolved RTL?" for a chart that has no `<Chart>`
* frame above it.
*
* Reads the RESOLVED direction off the element rather than a preference: a
* chart can sit in a subtree with its own `dir`, and the `ActiveEidos` the demo
* shell builds does not always carry the prefs the topbar writes (`getDir()`
* returns undefined there while the element computes `rtl` correctly). Same
* source `$ethereal/compute.ts` uses. `direction` is a computed STYLE, not
* geometry, so reading it forces no layout — and nothing else fires on a
* direction change, hence the attribute observer.
*/
export function createChartRtl(eidos: ActiveEidos) {
let rtl = $state(false);
let el = $state<Element | null>(null);
$effect(() => {
const node = el;
if (!node) return;
const read = () => {
rtl = eidos.dom.getWindow(node).getComputedStyle(node).direction === 'rtl';
};
read();
return eidos.dom.observeMutation(eidos.dom.getDocument(node).documentElement, read, {
attributes: true,
attributeFilter: ['dir']
});
});
return {
/** Bind to the element whose direction decides (`bind:this`). */
set ref(node: Element | null) {
el = node;
},
get ref() {
return el;
},
get current() {
return rtl;
},
/** `text-anchor` that lands on the intended physical side. */
anchor(a: 'start' | 'middle' | 'end') {
return physicalAnchor(a, rtl);
}
};
}
Loading…
Cancel
Save

Powered by TurnKey Linux.