bezier-slider 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>弧形图标滑块 - Demo</title>
7
+ <link rel="stylesheet" href="./shared/demo.css" />
8
+ <link rel="stylesheet" href="./shared/demo-native.css" />
9
+ </head>
10
+ <body class="demo-native">
11
+ <h1 class="title">弧形图标滑块 Demo</h1>
12
+
13
+ <div class="mode-tabs">
14
+ <button type="button" class="active" data-mode="svg">默认 SVG 滑轨</button>
15
+ <button type="button" data-mode="bg">背景图滑轨</button>
16
+ </div>
17
+
18
+ <p class="demo-desc" id="modeDesc">
19
+ 使用内置 renderDefaultTrack 在 onLayout 中绘制 SVG 滑轨。
20
+ </p>
21
+
22
+ <div class="demo-layout">
23
+ <aside class="params-panel" id="paramsPanel">
24
+ <div class="params-panel-header">
25
+ <h2>调节参数</h2>
26
+ <button
27
+ type="button"
28
+ class="params-reset"
29
+ id="paramsReset"
30
+ aria-label="恢复默认"
31
+ data-tooltip="恢复默认"
32
+ ></button>
33
+ </div>
34
+ <div id="paramsForm"></div>
35
+ </aside>
36
+
37
+ <div class="demo-stage">
38
+ <div class="bg-options hidden" id="bgOptions">
39
+ <label class="bg-upload-btn">
40
+ <input type="file" id="bgUpload" accept="image/*" />
41
+ 上传背景图
42
+ </label>
43
+ <span class="bg-file-name" id="bgFileName">内置示例背景</span>
44
+ <span class="bg-size-hint" id="bgSizeHint"></span>
45
+ <button type="button" class="bg-reset-btn hidden" id="bgReset">恢复示例</button>
46
+ </div>
47
+ <label class="demo-options hidden" id="debugOption">
48
+ <input type="checkbox" id="showDebugTrack" checked />
49
+ 显示轨迹调试线
50
+ </label>
51
+ <div class="slider-compose" id="sliderCompose">
52
+ <div class="carousel-bg" id="carouselBg"></div>
53
+ <div class="slider-track-layer" id="sliderMount"></div>
54
+ </div>
55
+ <div class="selected-info">
56
+ <div class="selected-icon" id="selectedIcon">🏠</div>
57
+ <div class="selected-name" id="selectedName">首页</div>
58
+ <div class="hint">← 拖动图标左右滑动 →</div>
59
+ </div>
60
+ <div class="legend">
61
+ <span><div class="legend-dot"></div> <span id="legendCenterT">centerT = 0.72</span></span>
62
+ <span id="legendExtra"></span>
63
+ </div>
64
+ </div>
65
+
66
+ <aside class="code-panel" id="codePanel">
67
+ <div class="code-panel-header">
68
+ <h2>输出代码</h2>
69
+ <button
70
+ type="button"
71
+ class="code-copy-btn"
72
+ id="codeCopyBtn"
73
+ data-tooltip="复制代码"
74
+ ></button>
75
+ </div>
76
+ <div class="code-tabs" id="codeTabs">
77
+ <button type="button" class="active" data-code="native">原生 JS</button>
78
+ <button type="button" data-code="react">React</button>
79
+ <button type="button" data-code="vue">Vue</button>
80
+ </div>
81
+ <pre class="code-content" id="codeContent"></pre>
82
+ </aside>
83
+ </div>
84
+
85
+ <div class="geometry-preset-bar" id="geometryPresetBar" aria-label="几何轨迹预设"></div>
86
+
87
+ <script type="module" src="./native-main.js"></script>
88
+ </body>
89
+ </html>
@@ -0,0 +1,309 @@
1
+ import { BezierSlider, renderDefaultTrack } from '../src/index.js';
2
+ import { ICONS, MODE_DESC, PARAM_SCHEMA, RESET_ICON_SVG, COPY_ICON_SVG } from './shared/constants.js';
3
+ import {
4
+ createDefaultParams,
5
+ buildSliderConfig,
6
+ deepClone,
7
+ setByPath,
8
+ syncParamsForm
9
+ } from './shared/param-utils.js';
10
+ import { bindParamsPanel } from './shared/params-panel.js';
11
+ import { bindGeometryPresetBar } from './shared/geometry-preset-bar.js';
12
+ import { bindCopyButton, bindResetButton } from './shared/clipboard.js';
13
+ import { CODE_FORMATTERS } from './shared/format-code.js';
14
+ import {
15
+ applyComposeLayout,
16
+ fitDefaultSvgSize,
17
+ fitImageDisplaySize,
18
+ getDisplaySizeHint
19
+ } from './shared/container-size.js';
20
+ import {
21
+ applyBgLayer,
22
+ clearBgLayer,
23
+ clearTrackArtifacts,
24
+ DEFAULT_BG_NATURAL,
25
+ loadImageNaturalSize,
26
+ readImageFile,
27
+ updateDebugTrack
28
+ } from './shared/track-helpers.js';
29
+
30
+ const MAX_BG_FILE_SIZE = 5 * 1024 * 1024;
31
+
32
+ document.getElementById('paramsReset').innerHTML = RESET_ICON_SVG;
33
+ document.getElementById('codeCopyBtn').innerHTML = `${COPY_ICON_SVG}复制`;
34
+
35
+ document.addEventListener('DOMContentLoaded', () => {
36
+ const sliderCompose = document.getElementById('sliderCompose');
37
+ const carouselBg = document.getElementById('carouselBg');
38
+ const sliderMount = document.getElementById('sliderMount');
39
+ const selectedIcon = document.getElementById('selectedIcon');
40
+ const selectedName = document.getElementById('selectedName');
41
+ const modeDesc = document.getElementById('modeDesc');
42
+ const bgOptions = document.getElementById('bgOptions');
43
+ const bgUpload = document.getElementById('bgUpload');
44
+ const bgFileName = document.getElementById('bgFileName');
45
+ const bgSizeHint = document.getElementById('bgSizeHint');
46
+ const bgReset = document.getElementById('bgReset');
47
+ const debugOption = document.getElementById('debugOption');
48
+ const showDebugTrack = document.getElementById('showDebugTrack');
49
+ const legendExtra = document.getElementById('legendExtra');
50
+ const legendCenterT = document.getElementById('legendCenterT');
51
+ const tabButtons = document.querySelectorAll('.mode-tabs button');
52
+ const codeTabButtons = document.querySelectorAll('#codeTabs button');
53
+ const paramsForm = document.getElementById('paramsForm');
54
+ const codeContent = document.getElementById('codeContent');
55
+ const codeCopyBtn = document.getElementById('codeCopyBtn');
56
+ const paramsReset = document.getElementById('paramsReset');
57
+ const geometryPresetBar = document.getElementById('geometryPresetBar');
58
+
59
+ let slider = null;
60
+ let currentMode = 'svg';
61
+ let currentCodeTab = 'native';
62
+ let params = createDefaultParams(BezierSlider.DEFAULTS);
63
+ let rebuildTimer = null;
64
+ let customBgUrl = null;
65
+ let customBgName = '';
66
+ let bgNaturalSize = { ...DEFAULT_BG_NATURAL };
67
+ let displaySize = fitDefaultSvgSize();
68
+
69
+ function getActiveBgUrl() {
70
+ return customBgUrl;
71
+ }
72
+
73
+ function updateBgSizeHint() {
74
+ if (!bgSizeHint) return;
75
+ const hint = getDisplaySizeHint(displaySize, params.trackScale);
76
+ if (currentMode === 'bg') {
77
+ bgSizeHint.textContent = `背景 ${hint.width}×${hint.height}px · 滑轨容器 ${hint.trackWidth}×${hint.trackHeight}px(trackScale=${hint.trackScale})`;
78
+ } else {
79
+ bgSizeHint.textContent = '';
80
+ }
81
+ }
82
+
83
+ function applyDisplayLayout() {
84
+ applyComposeLayout(sliderCompose, carouselBg, sliderMount, displaySize, params.trackScale);
85
+ updateBgSizeHint();
86
+ }
87
+
88
+ function getCodePreviewOptions() {
89
+ const sizeHint = getDisplaySizeHint(displaySize, params.trackScale);
90
+ return {
91
+ trackMode: currentMode === 'bg' ? 'bg' : 'svg',
92
+ displaySize: sizeHint
93
+ };
94
+ }
95
+
96
+ function updateParamsPreview() {
97
+ const formatter = CODE_FORMATTERS[currentCodeTab] ?? CODE_FORMATTERS.native;
98
+ codeContent.textContent = formatter(params, getCodePreviewOptions());
99
+ legendCenterT.textContent = `centerT = ${params.centerT}`;
100
+ }
101
+
102
+ function setCodeTab(tab) {
103
+ currentCodeTab = tab;
104
+ codeTabButtons.forEach((btn) => {
105
+ btn.classList.toggle('active', btn.dataset.code === tab);
106
+ });
107
+ updateParamsPreview();
108
+ }
109
+
110
+ function refreshBgOverlays() {
111
+ if (currentMode !== 'bg' || !slider) return;
112
+ const layout = slider.getLayoutState();
113
+ updateDebugTrack(sliderMount, layout, showDebugTrack.checked, BezierSlider);
114
+ }
115
+
116
+ async function resolveBgNaturalSize(url) {
117
+ if (!url) return { ...DEFAULT_BG_NATURAL };
118
+ try {
119
+ return await loadImageNaturalSize(url);
120
+ } catch {
121
+ return { ...DEFAULT_BG_NATURAL };
122
+ }
123
+ }
124
+
125
+ async function applyBackground(url, fileName = '') {
126
+ customBgUrl = url;
127
+ customBgName = fileName;
128
+ bgFileName.textContent = fileName || '内置示例背景';
129
+ bgReset.classList.toggle('hidden', !url);
130
+
131
+ if (currentMode === 'bg') {
132
+ bgNaturalSize = await resolveBgNaturalSize(url);
133
+ displaySize = fitImageDisplaySize(bgNaturalSize.width, bgNaturalSize.height);
134
+ applyDisplayLayout();
135
+ applyBgLayer(carouselBg, url);
136
+ if (slider) {
137
+ refreshBgOverlays();
138
+ } else {
139
+ createSlider('bg', false);
140
+ }
141
+ }
142
+ }
143
+
144
+ function createSlider(mode, keepIndex) {
145
+ const prevIndex = keepIndex && slider ? slider.getCurrentIndex() : params.initialIndex;
146
+
147
+ slider?.destroy();
148
+ clearTrackArtifacts(sliderMount);
149
+
150
+ const isBgMode = mode === 'bg';
151
+
152
+ if (isBgMode) {
153
+ displaySize = fitImageDisplaySize(bgNaturalSize.width, bgNaturalSize.height);
154
+ applyDisplayLayout();
155
+ applyBgLayer(carouselBg, getActiveBgUrl());
156
+ } else {
157
+ clearBgLayer(carouselBg);
158
+ displaySize = fitDefaultSvgSize();
159
+ applyDisplayLayout();
160
+ }
161
+
162
+ slider = new BezierSlider({
163
+ container: sliderMount,
164
+ icons: ICONS,
165
+ ...buildSliderConfig(params),
166
+ trackScale: 1,
167
+ onSelect: (icon) => {
168
+ selectedIcon.textContent = icon.emoji;
169
+ selectedName.textContent = icon.name;
170
+ },
171
+ onSlideEnd: (index) => console.log('停留下标:', index),
172
+ onLayout: (layout) => {
173
+ if (isBgMode) {
174
+ updateDebugTrack(sliderMount, layout, showDebugTrack.checked, BezierSlider);
175
+ } else {
176
+ renderDefaultTrack(sliderMount, layout);
177
+ }
178
+ }
179
+ });
180
+
181
+ if (keepIndex) {
182
+ slider.slideTo(prevIndex, false);
183
+ }
184
+ }
185
+
186
+ function scheduleRebuild() {
187
+ clearTimeout(rebuildTimer);
188
+ rebuildTimer = setTimeout(() => {
189
+ updateParamsPreview();
190
+ createSlider(currentMode, true);
191
+ }, 80);
192
+ }
193
+
194
+ function handleParamChange(path, value) {
195
+ if (path === 'fadeEnabled' || path === 'centerGlowEnabled') {
196
+ params[path] = value;
197
+ } else {
198
+ setByPath(params, path, value);
199
+ }
200
+
201
+ if (path === 'trackScale') {
202
+ applyDisplayLayout();
203
+ updateParamsPreview();
204
+ slider?.initLayout();
205
+ return;
206
+ }
207
+
208
+ scheduleRebuild();
209
+ }
210
+
211
+ function handleReset() {
212
+ params = createDefaultParams(BezierSlider.DEFAULTS);
213
+ syncParamsForm(paramsForm, params, PARAM_SCHEMA);
214
+ scheduleRebuild();
215
+ }
216
+
217
+ function handleGeometryPreset(preset) {
218
+ params.bezier = deepClone(preset.bezier);
219
+ syncParamsForm(paramsForm, params, PARAM_SCHEMA);
220
+ scheduleRebuild();
221
+ }
222
+
223
+ function setMode(mode) {
224
+ currentMode = mode;
225
+ tabButtons.forEach((btn) => {
226
+ btn.classList.toggle('active', btn.dataset.mode === mode);
227
+ });
228
+ modeDesc.textContent = MODE_DESC[mode];
229
+ bgOptions.classList.toggle('hidden', mode !== 'bg');
230
+ debugOption.classList.toggle('hidden', mode !== 'bg');
231
+ legendExtra.textContent = mode === 'bg'
232
+ ? (customBgUrl ? '自定义背景 + bezier 对齐' : '背景图 + bezier 对齐')
233
+ : '';
234
+ updateParamsPreview();
235
+ createSlider(mode, false);
236
+ }
237
+
238
+ const unbindPanel = bindParamsPanel(paramsForm, {
239
+ getParams: () => params,
240
+ onParamChange: handleParamChange,
241
+ schema: PARAM_SCHEMA
242
+ });
243
+ const unbindGeometryBar = bindGeometryPresetBar(geometryPresetBar, {
244
+ onPreset: handleGeometryPreset
245
+ });
246
+ const unbindReset = bindResetButton(paramsReset, handleReset);
247
+ const unbindCopy = bindCopyButton(codeCopyBtn, () => codeContent.textContent);
248
+
249
+ applyDisplayLayout();
250
+ updateParamsPreview();
251
+
252
+ tabButtons.forEach((btn) => {
253
+ btn.addEventListener('click', () => setMode(btn.dataset.mode));
254
+ });
255
+
256
+ codeTabButtons.forEach((btn) => {
257
+ btn.addEventListener('click', () => setCodeTab(btn.dataset.code));
258
+ });
259
+
260
+ bgUpload.addEventListener('change', async () => {
261
+ const file = bgUpload.files?.[0];
262
+ bgUpload.value = '';
263
+ if (!file) return;
264
+
265
+ if (file.size > MAX_BG_FILE_SIZE) {
266
+ window.alert('图片请小于 5MB');
267
+ return;
268
+ }
269
+
270
+ try {
271
+ const dataUrl = await readImageFile(file);
272
+ await applyBackground(dataUrl, file.name);
273
+ } catch (err) {
274
+ window.alert(err.message || '上传失败');
275
+ }
276
+ });
277
+
278
+ bgReset.addEventListener('click', async () => {
279
+ bgNaturalSize = { ...DEFAULT_BG_NATURAL };
280
+ await applyBackground(null);
281
+ });
282
+
283
+ showDebugTrack.addEventListener('change', () => {
284
+ if (currentMode === 'bg' && slider) {
285
+ updateDebugTrack(sliderMount, slider.getLayoutState(), showDebugTrack.checked, BezierSlider);
286
+ }
287
+ });
288
+
289
+ window.addEventListener('resize', () => {
290
+ if (currentMode === 'bg') {
291
+ displaySize = fitImageDisplaySize(bgNaturalSize.width, bgNaturalSize.height);
292
+ } else {
293
+ displaySize = fitDefaultSvgSize();
294
+ }
295
+ applyDisplayLayout();
296
+ slider?.initLayout();
297
+ updateParamsPreview();
298
+ });
299
+
300
+ setMode('svg');
301
+
302
+ window.addEventListener('beforeunload', () => {
303
+ unbindPanel();
304
+ unbindGeometryBar();
305
+ unbindReset();
306
+ unbindCopy();
307
+ slider?.destroy();
308
+ });
309
+ });
@@ -0,0 +1,59 @@
1
+ import { COPY_ICON_SVG, COPIED_ICON_SVG } from './constants.js';
2
+
3
+ export async function copyToClipboard(text) {
4
+ try {
5
+ await navigator.clipboard.writeText(text);
6
+ return true;
7
+ } catch {
8
+ const textarea = document.createElement('textarea');
9
+ textarea.value = text;
10
+ textarea.style.position = 'fixed';
11
+ textarea.style.left = '-9999px';
12
+ document.body.appendChild(textarea);
13
+ textarea.select();
14
+ try {
15
+ document.execCommand('copy');
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ } finally {
20
+ document.body.removeChild(textarea);
21
+ }
22
+ }
23
+ }
24
+
25
+ export function bindCopyButton(btn, getText) {
26
+ if (!btn) return () => {};
27
+
28
+ const handleCopy = async () => {
29
+ const success = await copyToClipboard(getText());
30
+ if (!success) return;
31
+
32
+ btn.classList.add('copied');
33
+ btn.innerHTML = `${COPIED_ICON_SVG}已复制`;
34
+ setTimeout(() => {
35
+ btn.classList.remove('copied');
36
+ btn.innerHTML = `${COPY_ICON_SVG}复制`;
37
+ }, 2000);
38
+ };
39
+
40
+ btn.addEventListener('click', handleCopy);
41
+ return () => btn.removeEventListener('click', handleCopy);
42
+ }
43
+
44
+ export function bindResetButton(btn, onReset) {
45
+ if (!btn) return () => {};
46
+ btn.addEventListener('click', onReset);
47
+ return () => btn.removeEventListener('click', onReset);
48
+ }
49
+
50
+ export function bindCodePreview(codeEl, getText) {
51
+ if (!codeEl) return () => {};
52
+
53
+ const update = () => {
54
+ codeEl.textContent = getText();
55
+ };
56
+
57
+ update();
58
+ return update;
59
+ }
@@ -0,0 +1,49 @@
1
+ export const ICONS = [
2
+ { name: '首页', emoji: '🏠', color: '#8b5cf6' },
3
+ { name: '搜索', emoji: '🔍', color: '#ec4899' },
4
+ { name: '消息', emoji: '💬', color: '#06b6d4' },
5
+ { name: '设置', emoji: '⚙️', color: '#22c55e' }
6
+ ];
7
+
8
+ export const PARAM_SCHEMA = [
9
+ { section: '布局', path: 'centerT', label: '中心位置', min: 0, max: 1, step: 0.01 },
10
+ { section: '布局', path: 'tStep', label: '图标间距', min: 0.01, max: 0.99, step: 0.01 },
11
+ { section: '布局', path: 'trackScale', label: '轨迹长度', min: 0.5, max: 2, step: 0.01 },
12
+ { section: '布局', path: 'initialIndex', label: '初始索引', min: 0, max: 10, step: 1 },
13
+ { section: '布局', path: 'visibleIconCount', label: '可见数量', min: 1, max: 10, step: 1 },
14
+ { section: '交互', path: 'sensitivity', label: '拖动灵敏度', min: 0.001, max: 0.02, step: 0.001 },
15
+ { section: '交互', path: 'snapDuration', label: '吸附时长(ms)', min: 100, max: 1000, step: 50 },
16
+ { section: '交互', path: 'rubberBandLimit', label: '拉扯限制', min: 0.1, max: 1, step: 0.05 },
17
+ { section: '交互', path: 'rubberBandDuration', label: '回弹时长(ms)', min: 200, max: 1000, step: 50 },
18
+ { section: '弧度', path: 'bezier.curveSmooth', label: '曲线平滑', min: -1, max: 2, step: 0.05 },
19
+ { section: '弧度', path: 'bezier.rightTilt', label: '右侧倾斜', min: -1, max: 2, step: 0.05 },
20
+ { section: '弧度', path: 'bezier.rightEndOffset', label: '右端偏移', min: -0.5, max: 0.5, step: 0.005 },
21
+ { section: '弧度', path: 'bezier.localBend.t', label: '弯曲位置', min: 0, max: 1, step: 0.01 },
22
+ { section: '弧度', path: 'bezier.localBend.degrees', label: '弯曲角度', min: -180, max: 180, step: 1 },
23
+ { section: '弧度', path: 'bezier.leftEndBend.degrees', label: '左末弯曲', min: -90, max: 90, step: 1 },
24
+ { section: '控制点 P0', path: 'bezier.fitted.p0.x', label: '起点 X', min: -1, max: 2, step: 0.005 },
25
+ { section: '控制点 P0', path: 'bezier.fitted.p0.y', label: '起点 Y', min: -1, max: 2, step: 0.01 },
26
+ { section: '控制点 P1', path: 'bezier.fitted.p1.x', label: '控制点 X', min: -1, max: 2, step: 0.01 },
27
+ { section: '控制点 P1', path: 'bezier.fitted.p1.y', label: '控制点 Y', min: -1, max: 3, step: 0.01 },
28
+ { section: '控制点 P2', path: 'bezier.fitted.p2.x', label: '终点 X', min: -1, max: 2, step: 0.005 },
29
+ { section: '控制点 P2', path: 'bezier.fitted.p2.y', label: '终点 Y', min: -1, max: 2, step: 0.01 }
30
+ ];
31
+
32
+ export const MODE_DESC = {
33
+ svg: 'SVG 滑轨由 renderDefaultTrack 绘制;左侧调 bezier 可实时改弧度。',
34
+ bg: '上传背景图按原图比例 1:1 展示;滑轨在独立图层,trackScale>1 可伸出背景外。'
35
+ };
36
+
37
+ export const RESET_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
38
+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
39
+ <path d="M3 3v5h5"/>
40
+ </svg>`;
41
+
42
+ export const COPY_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
43
+ <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
44
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
45
+ </svg>`;
46
+
47
+ export const COPIED_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
48
+ <polyline points="20 6 9 17 4 12"/>
49
+ </svg>`;
@@ -0,0 +1,77 @@
1
+ import { clampTrackScale } from './track-helpers.js';
2
+
3
+ export const DEFAULT_SVG_SIZE = { width: 480, height: 300 };
4
+
5
+ export const DEFAULT_BG_NATURAL = { width: 480, height: 300 };
6
+
7
+ const MAX_DISPLAY_WIDTH = 480;
8
+ const MAX_DISPLAY_HEIGHT = 360;
9
+
10
+ /** 按图片原始比例 1:1 换算展示尺寸(等比缩放,不拉伸) */
11
+ export function fitImageDisplaySize(naturalWidth, naturalHeight) {
12
+ const nw = naturalWidth || DEFAULT_BG_NATURAL.width;
13
+ const nh = naturalHeight || DEFAULT_BG_NATURAL.height;
14
+ const maxW = Math.min(window.innerWidth - 32, MAX_DISPLAY_WIDTH);
15
+ const maxH = Math.min(window.innerWidth * 0.75, MAX_DISPLAY_HEIGHT);
16
+ const scale = Math.min(maxW / nw, maxH / nh);
17
+ return {
18
+ width: Math.round(nw * scale),
19
+ height: Math.round(nh * scale),
20
+ naturalWidth: nw,
21
+ naturalHeight: nh
22
+ };
23
+ }
24
+
25
+ export function fitDefaultSvgSize() {
26
+ return fitImageDisplaySize(DEFAULT_SVG_SIZE.width, DEFAULT_SVG_SIZE.height);
27
+ }
28
+
29
+ /** 滑轨层尺寸:基准显示宽 × trackScale */
30
+ export function computeTrackLayerSize(baseDisplay, trackScale) {
31
+ const scale = clampTrackScale(trackScale);
32
+ const maxW = window.innerWidth - 32;
33
+ const trackW = Math.min(Math.round(baseDisplay.width * scale), maxW);
34
+ return {
35
+ width: trackW,
36
+ height: baseDisplay.height
37
+ };
38
+ }
39
+
40
+ /**
41
+ * 背景层固定 1:1 基准尺寸并居中;滑轨层随 trackScale 变宽/变窄,与背景中心对齐
42
+ */
43
+ export function applyComposeLayout(compose, bgLayer, trackLayer, baseDisplay, trackScale = 1) {
44
+ const trackSize = computeTrackLayerSize(baseDisplay, trackScale);
45
+ const composeWidth = Math.max(baseDisplay.width, trackSize.width);
46
+ const composeHeight = baseDisplay.height;
47
+ const bgLeft = (composeWidth - baseDisplay.width) / 2;
48
+ const trackLeft = (composeWidth - trackSize.width) / 2;
49
+
50
+ compose.style.width = `${composeWidth}px`;
51
+ compose.style.height = `${composeHeight}px`;
52
+
53
+ bgLayer.style.width = `${baseDisplay.width}px`;
54
+ bgLayer.style.height = `${baseDisplay.height}px`;
55
+ bgLayer.style.left = `${bgLeft}px`;
56
+ bgLayer.style.top = '0';
57
+
58
+ trackLayer.style.width = `${trackSize.width}px`;
59
+ trackLayer.style.height = `${trackSize.height}px`;
60
+ trackLayer.style.left = `${trackLeft}px`;
61
+ trackLayer.style.top = '0';
62
+
63
+ return { composeWidth, composeHeight, trackSize, baseDisplay };
64
+ }
65
+
66
+ export function getDisplaySizeHint(baseDisplay, trackScale = 1) {
67
+ const trackSize = computeTrackLayerSize(baseDisplay, trackScale);
68
+ return {
69
+ width: baseDisplay.width,
70
+ height: baseDisplay.height,
71
+ naturalWidth: baseDisplay.naturalWidth,
72
+ naturalHeight: baseDisplay.naturalHeight,
73
+ trackWidth: trackSize.width,
74
+ trackHeight: trackSize.height,
75
+ trackScale: clampTrackScale(trackScale)
76
+ };
77
+ }