chart-preview 1.1.0 → 1.3.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.
@@ -33,19 +33,29 @@ function isImageDecoderSupported(): boolean {
33
33
  return typeof ImageDecoder !== "undefined";
34
34
  }
35
35
 
36
+ /**
37
+ * Check if animations are supported on this browser.
38
+ * Use this to conditionally enable/disable animation features.
39
+ */
40
+ export function areAnimationsSupported(): boolean {
41
+ return isImageDecoderSupported();
42
+ }
43
+
36
44
  /**
37
45
  * Manages an animated WebP texture using the ImageDecoder API.
46
+ * Pre-decodes all frames during initialization for optimal performance.
38
47
  * Falls back to a static texture if ImageDecoder is not supported.
39
48
  */
40
49
  class AnimatedTexture {
41
50
  private canvas: HTMLCanvasElement;
42
51
  private ctx: CanvasRenderingContext2D;
43
52
  public texture: THREE.CanvasTexture;
44
- private decoder: ImageDecoder | null = null;
45
53
  private frameIndex = 0;
46
54
  private frameCount = 0;
47
55
  private lastFrameTime = 0;
48
56
  private frameDurations: number[] = [];
57
+ /** Pre-decoded frames stored as ImageBitmap for fast synchronous access */
58
+ private frameCache: ImageBitmap[] = [];
49
59
  private isAnimated = false;
50
60
  private disposed = false;
51
61
 
@@ -61,12 +71,12 @@ class AnimatedTexture {
61
71
  /**
62
72
  * Creates an AnimatedTexture from a URL.
63
73
  * Uses ImageDecoder for animation if available, otherwise loads as static texture.
74
+ * Pre-decodes all frames during initialization for optimal render performance.
64
75
  */
65
76
  static async create(url: string): Promise<AnimatedTexture | THREE.Texture> {
66
77
  if (!isImageDecoderSupported()) {
67
78
  // Fall back to static texture loading
68
- const textureLoader = new THREE.TextureLoader();
69
- return textureLoader.loadAsync(url);
79
+ return loadStaticTexture(url);
70
80
  }
71
81
 
72
82
  try {
@@ -80,8 +90,7 @@ class AnimatedTexture {
80
90
  const isSupported = await ImageDecoder.isTypeSupported(contentType);
81
91
  if (!isSupported) {
82
92
  // Fall back to static texture
83
- const textureLoader = new THREE.TextureLoader();
84
- return textureLoader.loadAsync(url);
93
+ return loadStaticTexture(url);
85
94
  }
86
95
 
87
96
  const decoder = new ImageDecoder({
@@ -113,37 +122,56 @@ class AnimatedTexture {
113
122
  return animTexture.texture;
114
123
  }
115
124
 
116
- // Animated image - decode first frame and set up animation
125
+ // Animated image - pre-decode ALL frames for optimal performance
117
126
  const firstResult = await decoder.decode({ frameIndex: 0 });
118
127
  const firstFrame = firstResult.image;
119
128
  const animTexture = new AnimatedTexture(
120
129
  firstFrame.displayWidth,
121
130
  firstFrame.displayHeight
122
131
  );
123
- animTexture.ctx.drawImage(firstFrame, 0, 0);
124
- animTexture.texture.needsUpdate = true;
125
132
 
126
- animTexture.decoder = decoder;
127
- animTexture.frameCount = frameCount;
128
- animTexture.isAnimated = true;
129
-
130
- // Pre-decode frame durations
133
+ // Pre-decode all frames into ImageBitmap cache
131
134
  animTexture.frameDurations = [];
135
+ animTexture.frameCache = [];
136
+
132
137
  for (let i = 0; i < frameCount; i++) {
133
138
  try {
134
139
  const frameResult = await decoder.decode({ frameIndex: i });
140
+ const videoFrame = frameResult.image;
141
+
142
+ // Create an ImageBitmap from the VideoFrame for fast synchronous access
143
+ const bitmap = await createImageBitmap(videoFrame);
144
+ animTexture.frameCache.push(bitmap);
145
+
135
146
  // Duration is in microseconds, convert to milliseconds
136
- const durationMs = (frameResult.image.duration ?? 100000) / 1000;
147
+ const durationMs = (videoFrame.duration ?? 100000) / 1000;
137
148
  animTexture.frameDurations.push(durationMs);
138
- frameResult.image.close();
149
+ videoFrame.close();
139
150
  } catch {
140
151
  animTexture.frameDurations.push(100); // Default 100ms
152
+ // If frame decode fails, reuse the last successful frame or create empty bitmap
153
+ if (animTexture.frameCache.length > 0) {
154
+ animTexture.frameCache.push(
155
+ animTexture.frameCache[animTexture.frameCache.length - 1]
156
+ );
157
+ }
141
158
  }
142
159
  }
143
160
 
144
- firstFrame.close();
161
+ // Draw the first frame
162
+ if (animTexture.frameCache.length > 0) {
163
+ animTexture.ctx.drawImage(animTexture.frameCache[0], 0, 0);
164
+ animTexture.texture.needsUpdate = true;
165
+ }
166
+
167
+ animTexture.frameCount = animTexture.frameCache.length;
168
+ animTexture.isAnimated = animTexture.frameCount > 1;
145
169
  animTexture.lastFrameTime = performance.now();
146
170
 
171
+ // Close the decoder - we no longer need it since all frames are cached
172
+ firstFrame.close();
173
+ decoder.close();
174
+
147
175
  return animTexture;
148
176
  } catch (error) {
149
177
  // Fall back to static texture on any error
@@ -151,17 +179,17 @@ class AnimatedTexture {
151
179
  "Failed to load animated texture, falling back to static:",
152
180
  error
153
181
  );
154
- const textureLoader = new THREE.TextureLoader();
155
- return textureLoader.loadAsync(url);
182
+ return loadStaticTexture(url);
156
183
  }
157
184
  }
158
185
 
159
186
  /**
160
187
  * Updates the texture to the current animation frame based on elapsed time.
161
- * Call this each frame in the render loop.
188
+ * This method is SYNCHRONOUS for optimal render performance - all frames
189
+ * are pre-decoded during initialization.
162
190
  */
163
- async tick(): Promise<void> {
164
- if (!this.isAnimated || !this.decoder || this.disposed) {
191
+ tick(): void {
192
+ if (!this.isAnimated || this.disposed || this.frameCache.length === 0) {
165
193
  return;
166
194
  }
167
195
 
@@ -173,16 +201,12 @@ class AnimatedTexture {
173
201
  this.frameIndex = (this.frameIndex + 1) % this.frameCount;
174
202
  this.lastFrameTime = now;
175
203
 
176
- try {
177
- const result = await this.decoder.decode({
178
- frameIndex: this.frameIndex,
179
- });
204
+ // Synchronous frame update from pre-decoded cache
205
+ const frame = this.frameCache[this.frameIndex];
206
+ if (frame) {
180
207
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
181
- this.ctx.drawImage(result.image, 0, 0);
208
+ this.ctx.drawImage(frame, 0, 0);
182
209
  this.texture.needsUpdate = true;
183
- result.image.close();
184
- } catch {
185
- // Frame decode failed, skip this frame
186
210
  }
187
211
  }
188
212
  }
@@ -192,14 +216,49 @@ class AnimatedTexture {
192
216
  */
193
217
  dispose(): void {
194
218
  this.disposed = true;
195
- if (this.decoder) {
196
- this.decoder.close();
197
- this.decoder = null;
219
+ // Close all cached ImageBitmaps
220
+ for (const bitmap of this.frameCache) {
221
+ bitmap.close();
198
222
  }
223
+ this.frameCache = [];
199
224
  this.texture.dispose();
200
225
  }
201
226
  }
202
227
 
228
+ /**
229
+ * Loads a static texture from a URL with proper error handling.
230
+ * Creates a fallback placeholder texture if loading fails.
231
+ */
232
+ async function loadStaticTexture(url: string): Promise<THREE.Texture> {
233
+ const textureLoader = new THREE.TextureLoader();
234
+ try {
235
+ const texture = await textureLoader.loadAsync(url);
236
+ texture.colorSpace = THREE.SRGBColorSpace;
237
+ return texture;
238
+ } catch (error) {
239
+ console.warn(`Failed to load static texture from ${url}:`, error);
240
+ // Create a small placeholder texture so notes still render
241
+ return createPlaceholderTexture();
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Creates a placeholder texture (magenta square) for missing textures.
247
+ * This ensures notes always render even if texture loading fails.
248
+ */
249
+ function createPlaceholderTexture(): THREE.Texture {
250
+ const canvas = document.createElement("canvas");
251
+ canvas.width = 32;
252
+ canvas.height = 32;
253
+ const ctx = canvas.getContext("2d")!;
254
+ // Draw a magenta square to make missing textures obvious
255
+ ctx.fillStyle = "#FF00FF";
256
+ ctx.fillRect(0, 0, 32, 32);
257
+ const texture = new THREE.CanvasTexture(canvas);
258
+ texture.colorSpace = THREE.SRGBColorSpace;
259
+ return texture;
260
+ }
261
+
203
262
  /** Collection of animated textures that need to be ticked each frame */
204
263
  class AnimatedTextureManager {
205
264
  private animatedTextures: AnimatedTexture[] = [];
@@ -210,8 +269,13 @@ class AnimatedTextureManager {
210
269
  }
211
270
  }
212
271
 
213
- async tick(): Promise<void> {
214
- await Promise.all(this.animatedTextures.map((t) => t.tick()));
272
+ /**
273
+ * Updates all animated textures. This is now SYNCHRONOUS for optimal performance.
274
+ */
275
+ tick(): void {
276
+ for (const texture of this.animatedTextures) {
277
+ texture.tick();
278
+ }
215
279
  }
216
280
 
217
281
  dispose(): void {
@@ -445,6 +509,19 @@ export class ChartPreview {
445
509
  return this.audioManager.chartEndTimeMs;
446
510
  }
447
511
 
512
+ /**
513
+ * Manually triggers a resize of the camera and renderer.
514
+ * Call this when the container size changes (e.g., entering/exiting fullscreen).
515
+ */
516
+ resize() {
517
+ this.camera.triggerResize();
518
+ this.renderer.triggerResize();
519
+ // Re-render a frame to immediately show the updated size
520
+ if (this.paused) {
521
+ this.animateFrame(false);
522
+ }
523
+ }
524
+
448
525
  /**
449
526
  * Cleans up all resources. Call this when discarding the preview.
450
527
  * The preview should not be used after calling dispose().
@@ -554,6 +631,11 @@ class ChartCamera extends THREE.PerspectiveCamera {
554
631
  this.updateProjectionMatrix();
555
632
  }
556
633
 
634
+ /** Manually trigger a resize update */
635
+ triggerResize() {
636
+ this.onResize();
637
+ }
638
+
557
639
  dispose() {
558
640
  window.removeEventListener("resize", this.resizeListener);
559
641
  this.clear();
@@ -578,6 +660,11 @@ class ChartRenderer extends THREE.WebGLRenderer {
578
660
  this.setSize(width, height);
579
661
  }
580
662
 
663
+ /** Manually trigger a resize update */
664
+ triggerResize() {
665
+ this.onResize();
666
+ }
667
+
581
668
  override dispose() {
582
669
  window.removeEventListener("resize", this.resizeListener);
583
670
  super.dispose();
@@ -1062,6 +1149,17 @@ class NotesManager {
1062
1149
  i++
1063
1150
  ) {
1064
1151
  const note = this.noteEvents[i];
1152
+
1153
+ // Get the material for this note type and flags
1154
+ const material = this.noteMaterials.get(note.type)?.get(note.flags);
1155
+ if (!material) {
1156
+ // Skip notes with missing textures (shouldn't happen, but prevents crashes)
1157
+ console.warn(
1158
+ `Missing texture for note type ${note.type} with flags ${note.flags}`
1159
+ );
1160
+ continue;
1161
+ }
1162
+
1065
1163
  const noteGroup = new THREE.Group();
1066
1164
  const scale =
1067
1165
  note.type === noteTypes.kick
@@ -1070,9 +1168,7 @@ class NotesManager {
1070
1168
  this.instrumentType === instrumentTypes.sixFret
1071
1169
  ? 0.04
1072
1170
  : SCALE;
1073
- const sprite = new THREE.Sprite(
1074
- this.noteMaterials.get(note.type)!.get(note.flags)!
1075
- );
1171
+ const sprite = new THREE.Sprite(material);
1076
1172
  noteGroup.add(sprite);
1077
1173
  sprite.center = new THREE.Vector2(
1078
1174
  note.type === noteTypes.kick ? 0.62 : 0.5,
@@ -1177,12 +1273,27 @@ class EventSequence<
1177
1273
  }
1178
1274
  }
1179
1275
 
1276
+ export interface LoadTexturesOptions {
1277
+ /**
1278
+ * Whether to enable animated textures.
1279
+ * When true (default), uses ImageDecoder API for animated WebP if available.
1280
+ * When false, always loads static textures for better performance.
1281
+ * Use `areAnimationsSupported()` to check if animations are supported on the current browser.
1282
+ */
1283
+ animationsEnabled?: boolean;
1284
+ }
1285
+
1180
1286
  /**
1181
1287
  * Loads all required textures for the chart preview.
1182
1288
  * @param instrumentType The type of instrument to load textures for
1289
+ * @param options Optional configuration for texture loading
1183
1290
  * @returns An object containing highway, strikeline, note textures, and animated texture manager
1184
1291
  */
1185
- async function loadTextures(instrumentType: InstrumentType) {
1292
+ async function loadTextures(
1293
+ instrumentType: InstrumentType,
1294
+ options: LoadTexturesOptions = {}
1295
+ ) {
1296
+ const { animationsEnabled = true } = options;
1186
1297
  const textureLoader = new THREE.TextureLoader();
1187
1298
  const animatedTextureManager = new AnimatedTextureManager();
1188
1299
 
@@ -1192,9 +1303,16 @@ async function loadTextures(instrumentType: InstrumentType) {
1192
1303
  /**
1193
1304
  * Loads a texture with animation support.
1194
1305
  * Uses ImageDecoder API for animated WebP when available, falls back to static.
1306
+ * If animationsEnabled is false, always loads static textures.
1195
1307
  */
1196
1308
  const loadAnimated = async (path: string): Promise<THREE.Texture> => {
1197
1309
  const url = `https://static.enchor.us/preview-${path}.webp`;
1310
+
1311
+ // If animations are disabled, load static texture directly
1312
+ if (!animationsEnabled) {
1313
+ return loadStaticTexture(url);
1314
+ }
1315
+
1198
1316
  const result = await AnimatedTexture.create(url);
1199
1317
 
1200
1318
  if (result instanceof AnimatedTexture) {
@@ -357,6 +357,11 @@ export class ChartPreviewPlayer extends HTMLElement {
357
357
  connectedCallback() {
358
358
  this.setAttribute("tabindex", "0");
359
359
  document.addEventListener("fullscreenchange", this.handleFullscreenChange);
360
+ // Safari uses webkit prefix
361
+ document.addEventListener(
362
+ "webkitfullscreenchange",
363
+ this.handleFullscreenChange
364
+ );
360
365
  }
361
366
 
362
367
  disconnectedCallback() {
@@ -365,6 +370,10 @@ export class ChartPreviewPlayer extends HTMLElement {
365
370
  "fullscreenchange",
366
371
  this.handleFullscreenChange
367
372
  );
373
+ document.removeEventListener(
374
+ "webkitfullscreenchange",
375
+ this.handleFullscreenChange
376
+ );
368
377
  this.removeEventListener("keydown", this.handleKeydown);
369
378
  }
370
379
 
@@ -467,8 +476,9 @@ export class ChartPreviewPlayer extends HTMLElement {
467
476
  }
468
477
  });
469
478
 
470
- // Fullscreen button
471
- this.fullscreenBtn.addEventListener("click", () => {
479
+ // Fullscreen button - stop propagation to prevent any parent handlers
480
+ this.fullscreenBtn.addEventListener("click", (e) => {
481
+ e.stopPropagation();
472
482
  this.toggleFullscreen();
473
483
  });
474
484
 
@@ -525,14 +535,32 @@ export class ChartPreviewPlayer extends HTMLElement {
525
535
  };
526
536
 
527
537
  private handleFullscreenChange = () => {
528
- this._isFullscreen = document.fullscreenElement === this.container;
538
+ // Check if we're in fullscreen mode (support both standard and webkit prefix)
539
+ const fullscreenElement =
540
+ document.fullscreenElement ||
541
+ (document as unknown as { webkitFullscreenElement?: Element })
542
+ .webkitFullscreenElement;
543
+ this._isFullscreen =
544
+ fullscreenElement !== null && fullscreenElement !== undefined;
529
545
  this.container.classList.toggle("fullscreen", this._isFullscreen);
530
546
  this.fullscreenBtn.innerHTML = this._isFullscreen
531
547
  ? ICONS.fullscreenExit
532
548
  : ICONS.fullscreen;
533
549
  this.fullscreenBtn.title = this._isFullscreen
534
550
  ? "Exit fullscreen"
535
- : "Toggle fullscreen";
551
+ : "Enter fullscreen";
552
+
553
+ // Trigger resize on the chart preview after a short delay
554
+ // to ensure the container dimensions have updated
555
+ if (this.chartPreview) {
556
+ // Use requestAnimationFrame to ensure DOM has updated
557
+ requestAnimationFrame(() => {
558
+ // Double RAF to ensure layout has been recalculated
559
+ requestAnimationFrame(() => {
560
+ this.chartPreview?.resize();
561
+ });
562
+ });
563
+ }
536
564
  };
537
565
 
538
566
  private onSeekStart() {
@@ -900,7 +928,8 @@ export class ChartPreviewPlayer extends HTMLElement {
900
928
  files,
901
929
  config.instrument,
902
930
  config.difficulty,
903
- config.initialSeekPercent
931
+ config.initialSeekPercent,
932
+ { animationsEnabled: config.animationsEnabled }
904
933
  );
905
934
 
906
935
  // Load the chart
@@ -948,7 +977,8 @@ export class ChartPreviewPlayer extends HTMLElement {
948
977
  files,
949
978
  config.instrument,
950
979
  config.difficulty,
951
- config.initialSeekPercent
980
+ config.initialSeekPercent,
981
+ { animationsEnabled: config.animationsEnabled }
952
982
  );
953
983
 
954
984
  // Load the chart
@@ -992,7 +1022,8 @@ export class ChartPreviewPlayer extends HTMLElement {
992
1022
  config.files,
993
1023
  config.instrument,
994
1024
  config.difficulty,
995
- config.initialSeekPercent
1025
+ config.initialSeekPercent,
1026
+ { animationsEnabled: config.animationsEnabled }
996
1027
  );
997
1028
 
998
1029
  // Load the chart
@@ -1105,12 +1136,46 @@ export class ChartPreviewPlayer extends HTMLElement {
1105
1136
  * Toggle fullscreen mode
1106
1137
  */
1107
1138
  async toggleFullscreen(): Promise<void> {
1108
- if (!document.fullscreenEnabled) return;
1139
+ // Check for fullscreen support (standard and webkit)
1140
+ const doc = document as unknown as {
1141
+ fullscreenEnabled?: boolean;
1142
+ webkitFullscreenEnabled?: boolean;
1143
+ fullscreenElement?: Element;
1144
+ webkitFullscreenElement?: Element;
1145
+ exitFullscreen?: () => Promise<void>;
1146
+ webkitExitFullscreen?: () => void;
1147
+ };
1148
+ const container = this.container as unknown as {
1149
+ requestFullscreen?: () => Promise<void>;
1150
+ webkitRequestFullscreen?: () => void;
1151
+ };
1152
+
1153
+ const isFullscreenEnabled =
1154
+ doc.fullscreenEnabled || doc.webkitFullscreenEnabled;
1155
+ if (!isFullscreenEnabled) return;
1156
+
1157
+ const fullscreenElement =
1158
+ doc.fullscreenElement || doc.webkitFullscreenElement;
1109
1159
 
1110
- if (this._isFullscreen) {
1111
- await document.exitFullscreen();
1112
- } else {
1113
- await this.container.requestFullscreen();
1160
+ try {
1161
+ if (fullscreenElement) {
1162
+ // Exit fullscreen
1163
+ if (doc.exitFullscreen) {
1164
+ await doc.exitFullscreen();
1165
+ } else if (doc.webkitExitFullscreen) {
1166
+ doc.webkitExitFullscreen();
1167
+ }
1168
+ } else {
1169
+ // Enter fullscreen
1170
+ if (container.requestFullscreen) {
1171
+ await container.requestFullscreen();
1172
+ } else if (container.webkitRequestFullscreen) {
1173
+ container.webkitRequestFullscreen();
1174
+ }
1175
+ }
1176
+ } catch (error) {
1177
+ // Fullscreen request can fail due to user gesture requirements or other reasons
1178
+ console.warn("Fullscreen toggle failed:", error);
1114
1179
  }
1115
1180
  }
1116
1181
 
package/src/SngLoader.ts CHANGED
@@ -7,7 +7,11 @@ import {
7
7
  type Instrument,
8
8
  type ScannedChart,
9
9
  } from "scan-chart";
10
- import { ChartPreview, type ParsedChart } from "./ChartPreview";
10
+ import {
11
+ ChartPreview,
12
+ type LoadTexturesOptions,
13
+ type ParsedChart,
14
+ } from "./ChartPreview";
11
15
 
12
16
  /**
13
17
  * Audio file extensions supported by the chart preview.
@@ -33,6 +37,11 @@ export interface LoadFromUrlConfig {
33
37
  initialSeekPercent?: number;
34
38
  /** AbortSignal to cancel the fetch operation */
35
39
  signal?: AbortSignal;
40
+ /**
41
+ * Whether to enable animated textures. Defaults to true.
42
+ * Set to false for better performance or when animations aren't supported.
43
+ */
44
+ animationsEnabled?: boolean;
36
45
  }
37
46
 
38
47
  /**
@@ -47,6 +56,11 @@ export interface LoadFromSngFileConfig {
47
56
  difficulty: Difficulty;
48
57
  /** Initial seek position as a percentage (0-1). Defaults to 0. */
49
58
  initialSeekPercent?: number;
59
+ /**
60
+ * Whether to enable animated textures. Defaults to true.
61
+ * Set to false for better performance or when animations aren't supported.
62
+ */
63
+ animationsEnabled?: boolean;
50
64
  }
51
65
 
52
66
  /**
@@ -61,6 +75,11 @@ export interface LoadFromChartFilesConfig {
61
75
  difficulty: Difficulty;
62
76
  /** Initial seek position as a percentage (0-1). Defaults to 0. */
63
77
  initialSeekPercent?: number;
78
+ /**
79
+ * Whether to enable animated textures. Defaults to true.
80
+ * Set to false for better performance or when animations aren't supported.
81
+ */
82
+ animationsEnabled?: boolean;
64
83
  }
65
84
 
66
85
  /**
@@ -218,13 +237,15 @@ export function isVideoFile(fileName: string): boolean {
218
237
  * @param instrument - The instrument to display
219
238
  * @param difficulty - The difficulty level to display
220
239
  * @param initialSeekPercent - Initial seek position (0-1)
240
+ * @param textureOptions - Options for texture loading (e.g., animations enabled)
221
241
  * @returns Prepared chart data ready for the player
222
242
  */
223
243
  export async function prepareChartData(
224
244
  files: { fileName: string; data: Uint8Array }[],
225
245
  instrument: Instrument,
226
246
  difficulty: Difficulty,
227
- initialSeekPercent = 0
247
+ initialSeekPercent = 0,
248
+ textureOptions: LoadTexturesOptions = {}
228
249
  ): Promise<PreparedChartData> {
229
250
  // Filter out video files
230
251
  const filteredFiles = files.filter((f) => !isVideoFile(f.fileName));
@@ -276,7 +297,10 @@ export async function prepareChartData(
276
297
 
277
298
  // Load textures for the instrument type
278
299
  const instrumentType = getInstrumentType(instrument);
279
- const textures = await ChartPreview.loadTextures(instrumentType);
300
+ const textures = await ChartPreview.loadTextures(
301
+ instrumentType,
302
+ textureOptions
303
+ );
280
304
 
281
305
  // Calculate audio length from the last note or from metadata
282
306
  const lastNote = parsedChart.trackData
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  // Core classes
2
2
  export {
3
3
  ChartPreview,
4
+ areAnimationsSupported,
4
5
  type ChartPreviewConfig,
5
6
  type ChartPreviewEvents,
7
+ type LoadTexturesOptions,
6
8
  type ParsedChart,
7
9
  } from "./ChartPreview";
8
10