PageSourceSearch

https://forthehearts.net/fth/shared/VariationAxes.js

js forthehearts.net collected 2026-10-02 07:54:18 UTC 14,475 bytes, 334 lines download raw bytes

1// =============================================================================
2// shared/VariationAxes.ts
3// =============================================================================
4import { saveInstanceIndex, getSavedInstanceIndex, saveAxisSettings, getSavedAxisSettings, saveLastChanged, getSavedLastChanged, } from './FontSession.js';
5import { VARIATION_DEFAULTS } from '../core/Config.js';
6export class VariationAxes {
7    container;
8    onChange;
9    axisContainer;
10    currentSettings = {};
11    sliderMap = {};
12    _instanceSelect = null;
13    _instances = [];
14    constructor(options) {
15        this.container = options.container;
16        this.onChange = options.onChange;
17        this.axisContainer = document.createElement('div');
18        this.axisContainer.className = 'axis-controls';
19        this.container.appendChild(this.axisContainer);
20    }
21    /**
22     * Creates sliders for variable-font axes and an optional named-instances
23     * dropdown.
24     */
25    createAxesControls(axes, instances = []) {
26        this.axisContainer.innerHTML = '';
27        this.currentSettings = {};
28        this.sliderMap = {};
29        // Remove any previously-inserted instances row.
30        this.container.querySelector('.instances-container')?.remove();
31        // ---- Named instances dropdown ------------------------------------------
32        if (instances.length > 0) {
33            const instancesRow = document.createElement('div');
34            instancesRow.className = 'slider-container instances-container';
35            const label = document.createElement('label');
36            label.textContent = 'Named instances';
37            label.htmlFor = 'font-instances';
38            const select = document.createElement('select');
39            select.className = 'instances-select';
40            select.id = 'font-instances';
41            select.name = 'font-instances';
42            instances.forEach((inst, i) => {
43                const option = document.createElement('option');
44                option.value = String(i);
45                option.textContent = inst.name;
46                select.appendChild(option);
47            });
48            select.addEventListener('change', () => {
49                const idx = parseInt(select.value, 10);
50                if (!isNaN(idx) && idx < instances.length) {
51                    saveInstanceIndex(idx);
52                    saveLastChanged('instance');
53                    this.setAxesValues(instances[idx].coordinates);
54                    saveAxisSettings({ ...this.currentSettings });
55                }
56            });
57            instancesRow.appendChild(label);
58            instancesRow.appendChild(select);
59            const styleContainer = this.container.querySelector('.style-buttons-wrapper')?.closest('.buttons-container');
60            if (styleContainer?.nextSibling) {
61                this.container.insertBefore(instancesRow, styleContainer.nextSibling);
62            }
63            else if (styleContainer) {
64                this.container.appendChild(instancesRow);
65            }
66            else {
67                const firstSlider = this.container.querySelector('.slider-container');
68                if (firstSlider) {
69                    this.container.insertBefore(instancesRow, firstSlider);
70                }
71                else {
72                    this.container.insertBefore(instancesRow, this.axisContainer);
73                }
74            }
75            this._instanceSelect = select;
76            this._instances = instances;
77        }
78        else {
79            this._instanceSelect = null;
80            this._instances = [];
81        }
82        // ---- Axis sliders -------------------------------------------------------
83        for (const axis of axes) {
84            const row = document.createElement('div');
85            row.className = 'slider-container variation-axis';
86            const sliderId = `axis-${axis.tag}`;
87            const label = document.createElement('label');
88            label.textContent = `${axis.name} (${axis.tag})`;
89            label.htmlFor = sliderId;
90            const slider = document.createElement('input');
91            slider.type = 'range';
92            slider.id = sliderId;
93            if (axis.avarMap) {
94                // Operate in avar-output space so the slider moves linearly through
95                // the design space rather than linearly through raw axis values.
96                const sliderMin = this._avarForward(this._normAxis(axis.min, axis), axis.avarMap);
97                const sliderMax = this._avarForward(this._normAxis(axis.max, axis), axis.avarMap);
98                const sliderDefault = this._avarForward(this._normAxis(axis.default, axis), axis.avarMap);
99                slider.min = String(sliderMin);
100                slider.max = String(sliderMax);
101                slider.value = String(sliderDefault);
102                slider.step = '0.001';
103            }
104            else {
105                slider.min = String(axis.min);
106                slider.max = String(axis.max);
107                slider.value = String(axis.default);
108                slider.step = '0.1';
109            }
110            const valueSpan = document.createElement('span');
111            valueSpan.className = 'value';
112            valueSpan.textContent = parseFloat(String(axis.default)).toFixed(1);
113            if (axis.avarMap) {
114                const avarMap = axis.avarMap;
115                slider.addEventListener('input', () => {
116                    const sliderVal = parseFloat(slider.value);
117                    const inputNorm = this._avarInverse(sliderVal, avarMap);
118                    const actualVal = this._denormAxis(inputNorm, axis);
119                    this._updateAxisValue(axis.tag, String(actualVal));
120                    valueSpan.textContent = actualVal.toFixed(1);
121                });
122            }
123            else {
124                slider.addEventListener('input', () => {
125                    this._updateAxisValue(axis.tag, slider.value);
126                    valueSpan.textContent = parseFloat(slider.value).toFixed(1);
127                });
128            }
129            this.currentSettings[axis.tag] = parseFloat(String(axis.default));
130            this.sliderMap[axis.tag] = { slider, valueSpan, axis };
131            row.appendChild(label);
132            row.appendChild(slider);
133            row.appendChild(valueSpan);
134            this.axisContainer.appendChild(row);
135        }
136        // ---- Restore persisted state --------------------------------------------
137        const lastChanged = getSavedLastChanged();
138        const savedAxisSettings = getSavedAxisSettings();
139        // 1. User has previously moved sliders → restore those.
140        if (lastChanged === 'sliders' && savedAxisSettings) {
141            this.setAxesValues(savedAxisSettings);
142            return;
143        }
144        // 2. User has previously selected a named instance → restore that.
145        if (this._instanceSelect && this._instances.length > 0) {
146            const savedIdx = getSavedInstanceIndex();
147            if (savedIdx !== null && savedIdx < this._instances.length) {
148                this._instanceSelect.value = String(savedIdx);
149                this.setAxesValues(this._instances[savedIdx].coordinates);
150                return;
151            }
152        }
153        // 3. First visit — apply VARIATION_DEFAULTS from Config.ts.
154        const configInstance = VARIATION_DEFAULTS.namedInstance;
155        const configAxes = VARIATION_DEFAULTS.axes;
156        const hasAxisOverrides = Object.values(configAxes).some(v => v !== null);
157        if (configInstance !== null && this._instances.length > 0) {
158            const idx = this._findInstanceIndex(configInstance);
159            if (idx >= 0) {
160                if (this._instanceSelect)
161                    this._instanceSelect.value = String(idx);
162                const coords = { ...this._instances[idx].coordinates };
163                for (const [tag, val] of Object.entries(configAxes)) {
164                    if (val !== null)
165                        coords[tag] = val;
166                }
167                this.setAxesValues(coords);
168                return;
169            }
170        }
171        if (hasAxisOverrides) {
172            const coords = { ...this.currentSettings };
173            for (const [tag, val] of Object.entries(configAxes)) {
174                if (val !== null)
175                    coords[tag] = val;
176            }
177            this.setAxesValues(coords);
178            return;
179        }
180        // Emit initial settings from axis defaults.
181        this._updateVariationSettings();
182    }
183    /**
184     * Sets all axis sliders to the supplied coordinate values and triggers the
185     * onChange callback.
186     */
187    setAxesValues(coordinates) {
188        for (const [tag, val] of Object.entries(coordinates)) {
189            this.currentSettings[tag] = val;
190            const entry = this.sliderMap[tag];
191            if (entry) {
192                if (entry.axis.avarMap) {
193                    // Convert the actual axis value → normalised → avar-output space.
194                    const norm = this._normAxis(val, entry.axis);
195                    const sliderVal = this._avarForward(norm, entry.axis.avarMap);
196                    entry.slider.value = String(sliderVal);
197                }
198                else {
199                    entry.slider.value = String(val);
200                }
201                entry.valueSpan.textContent = parseFloat(String(val)).toFixed(1);
202            }
203        }
204        this._updateVariationSettings();
205    }
206    _updateAxisValue(tag, value) {
207        const numValue = parseFloat(value);
208        if (isNaN(numValue)) {
209            delete this.currentSettings[tag];
210        }
211        else {
212            this.currentSettings[tag] = numValue;
213        }
214        saveAxisSettings({ ...this.currentSettings });
215        saveLastChanged('sliders');
216        this._updateVariationSettings();
217    }
218    _updateVariationSettings() {
219        const settings = Object.entries(this.currentSettings)
220            .filter(([, val]) => !isNaN(val))
221            .map(([tag, val]) => `"${tag}" ${val.toFixed(1)}`)
222            .join(', ');
223        this.onChange(settings || 'normal');
224    }
225    // ---------------------------------------------------------------------------
226    // Avar / normalisation helpers
227    // ---------------------------------------------------------------------------
228    /**
229     * Normalise an actual axis value to the [-1, 1] coordinate space used by
230     * the avar table, following the OpenType fvar normalisation rules.
231     */
232    _normAxis(value, axis) {
233        if (value === axis.default)
234            return 0;
235        if (value > axis.default) {
236            return axis.max === axis.default
237                ? 0
238                : (value - axis.default) / (axis.max - axis.default);
239        }
240        // value < axis.default
241        return axis.min === axis.default
242            ? 0
243            : (value - axis.default) / (axis.default - axis.min);
244    }
245    /**
246     * Denormalise from the [-1, 1] coordinate space back to an actual axis
247     * value.
248     */
249    _denormAxis(norm, axis) {
250        if (norm === 0)
251            return axis.default;
252        if (norm > 0)
253            return axis.default + norm * (axis.max - axis.default);
254        /* norm < 0 */ return axis.default + norm * (axis.default - axis.min);
255    }
256    /**
257     * Evaluate the avar piecewise-linear segment map in the forward direction
258     * (fvar-normalised input → avar-remapped output).
259     */
260    _avarForward(input, map) {
261        if (map.length === 0)
262            return input;
263        const first = map[0];
264        const last = map[map.length - 1];
265        if (input <= first.fromCoordinate)
266            return first.toCoordinate;
267        if (input >= last.fromCoordinate)
268            return last.toCoordinate;
269        for (let i = 0; i < map.length - 1; i++) {
270            const p0 = map[i];
271            const p1 = map[i + 1];
272            if (input >= p0.fromCoordinate && input <= p1.fromCoordinate) {
273                const dFrom = p1.fromCoordinate - p0.fromCoordinate;
274                if (dFrom === 0)
275                    return p0.toCoordinate;
276                const t = (input - p0.fromCoordinate) / dFrom;
277                return p0.toCoordinate + t * (p1.toCoordinate - p0.toCoordinate);
278            }
279        }
280        return input;
281    }
282    /**
283     * Evaluate the avar piecewise-linear segment map in the inverse direction
284     * (avar-remapped output → fvar-normalised input).  Requires the
285     * toCoordinates to be monotonically non-decreasing, which is guaranteed
286     * for a well-formed avar table.
287     */
288    _avarInverse(output, map) {
289        if (map.length === 0)
290            return output;
291        const first = map[0];
292        const last = map[map.length - 1];
293        if (output <= first.toCoordinate)
294            return first.fromCoordinate;
295        if (output >= last.toCoordinate)
296            return last.fromCoordinate;
297        for (let i = 0; i < map.length - 1; i++) {
298            const p0 = map[i];
299            const p1 = map[i + 1];
300            if (output >= p0.toCoordinate && output <= p1.toCoordinate) {
301                const dTo = p1.toCoordinate - p0.toCoordinate;
302                if (dTo === 0)
303                    return p0.fromCoordinate;
304                const t = (output - p0.toCoordinate) / dTo;
305                return p0.fromCoordinate + t * (p1.fromCoordinate - p0.fromCoordinate);
306            }
307        }
308        return output;
309    }
310    /**
311     * Finds the index of a named instance by name, with a fallback that strips
312     * style particles (e.g. ' Italic', ' Oblique') from the end of the name so
313     * that 'SubHead SemiExp SemiBold Italic' matches 'SubHead SemiExp SemiBold'
314     * when switching to a Roman font file.
315     */
316    _findInstanceIndex(name) {
317        // 1. Exact match.
318        const exact = this._instances.findIndex(inst => inst.name === name);
319        if (exact >= 0)
320            return exact;
321        // 2. Strip trailing style particles and try again.
322        const STYLE_SUFFIXES = [' Italic', ' Oblique', ' Ital'];
323        for (const suffix of STYLE_SUFFIXES) {
324            if (name.endsWith(suffix)) {
325                const stripped = name.slice(0, -suffix.length).trimEnd();
326                const fallback = this._instances.findIndex(inst => inst.name === stripped);
327                if (fallback >= 0)
328                    return fallback;
329            }
330        }
331        return -1;
332    }
333}
334//# sourceMappingURL=VariationAxes.js.map

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.