@qfo/qfchart 0.6.8 → 0.7.1

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.
@@ -171,19 +171,23 @@ export class SeriesBuilder {
171
171
  let value = point.value;
172
172
  const pointColor = point.options?.color;
173
173
 
174
- // TradingView compatibility: if color is 'na' (NaN, null, or "na"), break the line
174
+ // TradingView compatibility: if color is 'na' (NaN, null, undefined, or "na"), break the line
175
+ // When the options object explicitly has a 'color' key set to undefined,
176
+ // this means PineTS evaluated the color expression to na (hidden segment).
177
+ const hasExplicitColorKey = point.options != null && 'color' in point.options;
175
178
  const isNaColor =
176
179
  pointColor === null ||
177
180
  pointColor === 'na' ||
178
181
  pointColor === 'NaN' ||
179
- (typeof pointColor === 'number' && isNaN(pointColor));
182
+ (typeof pointColor === 'number' && isNaN(pointColor)) ||
183
+ (hasExplicitColorKey && pointColor === undefined);
180
184
 
181
185
  if (isNaColor) {
182
186
  value = null;
183
187
  }
184
188
 
185
189
  dataArray[offsetIndex] = value;
186
- colorArray[offsetIndex] = pointColor || plot.options.color || SeriesBuilder.DEFAULT_COLOR;
190
+ colorArray[offsetIndex] = isNaColor ? null : (pointColor || plot.options.color || SeriesBuilder.DEFAULT_COLOR);
187
191
  optionsArray[offsetIndex] = point.options || {};
188
192
  }
189
193
  }
@@ -223,6 +227,11 @@ export class SeriesBuilder {
223
227
  return; // Skip rendering a series for barcolor
224
228
  }
225
229
 
230
+ // Tables are rendered as DOM overlays, not ECharts series
231
+ if (plot.options.style === 'table') {
232
+ return;
233
+ }
234
+
226
235
  // Use Factory to get appropriate renderer
227
236
  const renderer = SeriesRendererFactory.get(plot.options.style);
228
237
  const seriesConfig = renderer.render({
@@ -10,6 +10,8 @@ import { FillRenderer } from './renderers/FillRenderer';
10
10
  import { LabelRenderer } from './renderers/LabelRenderer';
11
11
  import { DrawingLineRenderer } from './renderers/DrawingLineRenderer';
12
12
  import { LinefillRenderer } from './renderers/LinefillRenderer';
13
+ import { PolylineRenderer } from './renderers/PolylineRenderer';
14
+ import { BoxRenderer } from './renderers/BoxRenderer';
13
15
 
14
16
  export class SeriesRendererFactory {
15
17
  private static renderers: Map<string, SeriesRenderer> = new Map();
@@ -30,6 +32,8 @@ export class SeriesRendererFactory {
30
32
  this.register('label', new LabelRenderer());
31
33
  this.register('drawing_line', new DrawingLineRenderer());
32
34
  this.register('linefill', new LinefillRenderer());
35
+ this.register('drawing_polyline', new PolylineRenderer());
36
+ this.register('drawing_box', new BoxRenderer());
33
37
  }
34
38
 
35
39
  public static register(style: string, renderer: SeriesRenderer) {
@@ -0,0 +1,322 @@
1
+ import { ColorUtils } from '../utils/ColorUtils';
2
+
3
+ /**
4
+ * Renders Pine Script table objects as HTML DOM overlays positioned
5
+ * absolutely over the ECharts chart canvas.
6
+ *
7
+ * Tables use fixed positions (top_left, bottom_center, etc.) rather
8
+ * than data coordinates, so they are rendered as HTML elements instead
9
+ * of ECharts custom series.
10
+ */
11
+ export class TableOverlayRenderer {
12
+
13
+ /**
14
+ * Parse a color value for table rendering.
15
+ * Unlike ColorUtils.parseColor (which defaults to 0.3 opacity for fills),
16
+ * tables treat hex/named colors as fully opaque — only rgba provides opacity.
17
+ */
18
+ private static safeParseColor(val: any): { color: string; opacity: number } {
19
+ if (!val || typeof val !== 'string') {
20
+ return { color: '#888888', opacity: 1 };
21
+ }
22
+ // Extract opacity from rgba(), otherwise assume fully opaque
23
+ const rgbaMatch = val.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
24
+ if (rgbaMatch) {
25
+ const a = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1;
26
+ return { color: `rgb(${rgbaMatch[1]},${rgbaMatch[2]},${rgbaMatch[3]})`, opacity: a };
27
+ }
28
+ return { color: val, opacity: 1 };
29
+ }
30
+
31
+ /**
32
+ * Clear all existing table overlays and render new ones.
33
+ * @param gridRect The ECharts grid rect {x, y, width, height} in pixels,
34
+ * representing the actual plot area within the container.
35
+ */
36
+ static render(container: HTMLElement, tables: any[], gridRect?: { x: number; y: number; width: number; height: number }): void {
37
+ TableOverlayRenderer.clearAll(container);
38
+
39
+ // Pine Script: only the last table at each position is displayed
40
+ const byPosition = new Map<string, any>();
41
+ for (const tbl of tables) {
42
+ if (tbl && !tbl._deleted) {
43
+ byPosition.set(tbl.position, tbl);
44
+ }
45
+ }
46
+
47
+ byPosition.forEach((tbl) => {
48
+ const el = TableOverlayRenderer.buildTable(tbl);
49
+ TableOverlayRenderer.positionTable(el, tbl.position, gridRect);
50
+ container.appendChild(el);
51
+ });
52
+ }
53
+
54
+ static clearAll(container: HTMLElement): void {
55
+ while (container.firstChild) {
56
+ container.removeChild(container.firstChild);
57
+ }
58
+ }
59
+
60
+ private static buildTable(tbl: any): HTMLElement {
61
+ const table = document.createElement('table');
62
+ table.style.borderCollapse = 'separate';
63
+ table.style.borderSpacing = '0';
64
+ table.style.pointerEvents = 'auto';
65
+ table.style.fontSize = '14px';
66
+ table.style.lineHeight = '1.4';
67
+ table.style.fontFamily = 'sans-serif';
68
+ table.style.margin = '4px';
69
+
70
+ // Table background
71
+ if (tbl.bgcolor) {
72
+ const { color, opacity } = TableOverlayRenderer.safeParseColor(tbl.bgcolor);
73
+ table.style.backgroundColor = color;
74
+ if (opacity < 1) table.style.opacity = String(opacity);
75
+ }
76
+
77
+ // Frame (outer border)
78
+ if (tbl.frame_width > 0 && tbl.frame_color) {
79
+ const { color: fc } = TableOverlayRenderer.safeParseColor(tbl.frame_color);
80
+ table.style.border = `${tbl.frame_width}px solid ${fc}`;
81
+ } else if (tbl.frame_width > 0) {
82
+ table.style.border = `${tbl.frame_width}px solid #999`;
83
+ }
84
+
85
+ // Build merge lookup: for each cell, determine colspan/rowspan
86
+ const mergeMap = new Map<string, { colspan: number; rowspan: number }>();
87
+ const mergedCells = new Set<string>();
88
+
89
+ if (tbl.merges) {
90
+ for (const m of tbl.merges) {
91
+ const key = `${m.startCol},${m.startRow}`;
92
+ mergeMap.set(key, {
93
+ colspan: m.endCol - m.startCol + 1,
94
+ rowspan: m.endRow - m.startRow + 1,
95
+ });
96
+ // Mark all cells covered by this merge (except the origin)
97
+ for (let r = m.startRow; r <= m.endRow; r++) {
98
+ for (let c = m.startCol; c <= m.endCol; c++) {
99
+ if (r === m.startRow && c === m.startCol) continue;
100
+ mergedCells.add(`${c},${r}`);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ // Build rows
107
+ const rows = tbl.rows || 0;
108
+ const cols = tbl.columns || 0;
109
+
110
+ for (let r = 0; r < rows; r++) {
111
+ const tr = document.createElement('tr');
112
+
113
+ for (let c = 0; c < cols; c++) {
114
+ const cellKey = `${c},${r}`;
115
+
116
+ // Skip cells that are covered by a merge
117
+ if (mergedCells.has(cellKey)) continue;
118
+
119
+ const td = document.createElement('td');
120
+
121
+ // Apply merge attributes
122
+ const merge = mergeMap.get(cellKey);
123
+ if (merge) {
124
+ if (merge.colspan > 1) td.colSpan = merge.colspan;
125
+ if (merge.rowspan > 1) td.rowSpan = merge.rowspan;
126
+ }
127
+
128
+ // Cell borders
129
+ if (tbl.border_width > 0) {
130
+ const bc = tbl.border_color
131
+ ? TableOverlayRenderer.safeParseColor(tbl.border_color).color
132
+ : '#999';
133
+ td.style.border = `${tbl.border_width}px solid ${bc}`;
134
+ }
135
+
136
+ // Get cell data
137
+ const cellData = tbl.cells?.[r]?.[c];
138
+ if (cellData && !cellData._merged) {
139
+ // Cell text
140
+ td.textContent = cellData.text || '';
141
+
142
+ // Cell background
143
+ if (cellData.bgcolor) {
144
+ const { color: bg, opacity: bgOp } = TableOverlayRenderer.safeParseColor(cellData.bgcolor);
145
+ td.style.backgroundColor = bg;
146
+ if (bgOp < 1) {
147
+ // Use rgba for cell-level opacity to avoid affecting text
148
+ td.style.backgroundColor = cellData.bgcolor;
149
+ }
150
+ }
151
+
152
+ // Text color
153
+ if (cellData.text_color) {
154
+ const { color: tc } = TableOverlayRenderer.safeParseColor(cellData.text_color);
155
+ td.style.color = tc;
156
+ }
157
+
158
+ // Text size
159
+ td.style.fontSize = TableOverlayRenderer.getSizePixels(cellData.text_size) + 'px';
160
+
161
+ // Text alignment
162
+ td.style.textAlign = TableOverlayRenderer.mapHAlign(cellData.text_halign);
163
+ td.style.verticalAlign = TableOverlayRenderer.mapVAlign(cellData.text_valign);
164
+
165
+ // Font family
166
+ if (cellData.text_font_family === 'monospace') {
167
+ td.style.fontFamily = 'monospace';
168
+ }
169
+
170
+ // Width/height (% of chart area, 0 = auto)
171
+ if (cellData.width > 0) {
172
+ td.style.width = cellData.width + '%';
173
+ }
174
+ if (cellData.height > 0) {
175
+ td.style.height = cellData.height + '%';
176
+ }
177
+
178
+ // Tooltip
179
+ if (cellData.tooltip) {
180
+ td.title = cellData.tooltip;
181
+ }
182
+ }
183
+
184
+ // Default padding
185
+ td.style.padding = '4px 6px';
186
+ td.style.whiteSpace = 'nowrap';
187
+
188
+ tr.appendChild(td);
189
+ }
190
+
191
+ table.appendChild(tr);
192
+ }
193
+
194
+ return table;
195
+ }
196
+
197
+ private static positionTable(
198
+ el: HTMLElement,
199
+ position: string,
200
+ gridRect?: { x: number; y: number; width: number; height: number },
201
+ ): void {
202
+ el.style.position = 'absolute';
203
+
204
+ // Use grid rect (actual plot area) if available, otherwise fall back to container edges.
205
+ // Inset bottom/right by a few pixels so tables don't touch the axis lines.
206
+ const PAD = 8;
207
+ const top = gridRect ? gridRect.y + 'px' : '0';
208
+ const left = gridRect ? gridRect.x + 'px' : '0';
209
+ const bottom = gridRect ? (gridRect.y + gridRect.height - PAD) + 'px' : '0';
210
+ const right = gridRect ? (gridRect.x + gridRect.width - PAD) + 'px' : '0';
211
+ const centerX = gridRect ? (gridRect.x + gridRect.width / 2) + 'px' : '50%';
212
+ const centerY = gridRect ? (gridRect.y + gridRect.height / 2) + 'px' : '50%';
213
+
214
+ switch (position) {
215
+ case 'top_left':
216
+ el.style.top = top;
217
+ el.style.left = left;
218
+ break;
219
+ case 'top_center':
220
+ el.style.top = top;
221
+ el.style.left = centerX;
222
+ el.style.transform = 'translateX(-50%)';
223
+ break;
224
+ case 'top_right':
225
+ el.style.top = top;
226
+ el.style.left = right;
227
+ el.style.transform = 'translateX(-100%)';
228
+ break;
229
+ case 'middle_left':
230
+ el.style.top = centerY;
231
+ el.style.left = left;
232
+ el.style.transform = 'translateY(-50%)';
233
+ break;
234
+ case 'middle_center':
235
+ el.style.top = centerY;
236
+ el.style.left = centerX;
237
+ el.style.transform = 'translate(-50%, -50%)';
238
+ break;
239
+ case 'middle_right':
240
+ el.style.top = centerY;
241
+ el.style.left = right;
242
+ el.style.transform = 'translate(-100%, -50%)';
243
+ break;
244
+ case 'bottom_left':
245
+ el.style.top = bottom;
246
+ el.style.left = left;
247
+ el.style.transform = 'translateY(-100%)';
248
+ break;
249
+ case 'bottom_center':
250
+ el.style.top = bottom;
251
+ el.style.left = centerX;
252
+ el.style.transform = 'translate(-50%, -100%)';
253
+ break;
254
+ case 'bottom_right':
255
+ el.style.top = bottom;
256
+ el.style.left = right;
257
+ el.style.transform = 'translate(-100%, -100%)';
258
+ break;
259
+ default:
260
+ el.style.top = top;
261
+ el.style.left = right;
262
+ el.style.transform = 'translateX(-100%)';
263
+ break;
264
+ }
265
+ }
266
+
267
+ private static getSizePixels(size: string | number): number {
268
+ if (typeof size === 'number' && size > 0) return size;
269
+ switch (size) {
270
+ case 'auto':
271
+ case 'size.auto':
272
+ return 12;
273
+ case 'tiny':
274
+ case 'size.tiny':
275
+ return 8;
276
+ case 'small':
277
+ case 'size.small':
278
+ return 10;
279
+ case 'normal':
280
+ case 'size.normal':
281
+ return 14;
282
+ case 'large':
283
+ case 'size.large':
284
+ return 20;
285
+ case 'huge':
286
+ case 'size.huge':
287
+ return 36;
288
+ default:
289
+ return 14;
290
+ }
291
+ }
292
+
293
+ private static mapHAlign(align: string): string {
294
+ switch (align) {
295
+ case 'left':
296
+ case 'text.align_left':
297
+ return 'left';
298
+ case 'right':
299
+ case 'text.align_right':
300
+ return 'right';
301
+ case 'center':
302
+ case 'text.align_center':
303
+ default:
304
+ return 'center';
305
+ }
306
+ }
307
+
308
+ private static mapVAlign(align: string): string {
309
+ switch (align) {
310
+ case 'top':
311
+ case 'text.align_top':
312
+ return 'top';
313
+ case 'bottom':
314
+ case 'text.align_bottom':
315
+ return 'bottom';
316
+ case 'center':
317
+ case 'text.align_center':
318
+ default:
319
+ return 'middle';
320
+ }
321
+ }
322
+ }
@@ -0,0 +1,258 @@
1
+ import { SeriesRenderer, RenderContext } from './SeriesRenderer';
2
+
3
+ /**
4
+ * Convert any color string to a format ECharts canvas can render with opacity.
5
+ * 8-digit hex (#RRGGBBAA) is not universally supported by canvas — convert to rgba().
6
+ */
7
+ function normalizeColor(color: string | undefined): string | undefined {
8
+ if (!color || typeof color !== 'string') return color;
9
+ if (color.startsWith('#')) {
10
+ const hex = color.slice(1);
11
+ if (hex.length === 8) {
12
+ const r = parseInt(hex.slice(0, 2), 16);
13
+ const g = parseInt(hex.slice(2, 4), 16);
14
+ const b = parseInt(hex.slice(4, 6), 16);
15
+ const a = parseInt(hex.slice(6, 8), 16) / 255;
16
+ return `rgba(${r},${g},${b},${a.toFixed(3)})`;
17
+ }
18
+ }
19
+ return color;
20
+ }
21
+
22
+ /**
23
+ * Renderer for Pine Script box.* drawing objects.
24
+ * Each box is defined by two corners (left,top) → (right,bottom)
25
+ * with fill, border, optional text, and optional extend.
26
+ *
27
+ * Style name: 'drawing_box' (distinct from other styles).
28
+ */
29
+ export class BoxRenderer implements SeriesRenderer {
30
+ render(context: RenderContext): any {
31
+ const { seriesName, xAxisIndex, yAxisIndex, dataArray, dataIndexOffset } = context;
32
+ const offset = dataIndexOffset || 0;
33
+
34
+ // Collect all non-deleted box objects from the sparse dataArray.
35
+ const boxObjects: any[] = [];
36
+
37
+ for (let i = 0; i < dataArray.length; i++) {
38
+ const val = dataArray[i];
39
+ if (!val) continue;
40
+
41
+ const items = Array.isArray(val) ? val : [val];
42
+ for (const bx of items) {
43
+ if (bx && typeof bx === 'object' && !bx._deleted) {
44
+ boxObjects.push(bx);
45
+ }
46
+ }
47
+ }
48
+
49
+ if (boxObjects.length === 0) {
50
+ return { name: seriesName, type: 'custom', xAxisIndex, yAxisIndex, data: [], silent: true };
51
+ }
52
+
53
+ // Compute y-range for axis scaling
54
+ let yMin = Infinity, yMax = -Infinity;
55
+ for (const bx of boxObjects) {
56
+ if (bx.top < yMin) yMin = bx.top;
57
+ if (bx.top > yMax) yMax = bx.top;
58
+ if (bx.bottom < yMin) yMin = bx.bottom;
59
+ if (bx.bottom > yMax) yMax = bx.bottom;
60
+ }
61
+
62
+ // Use a SINGLE data entry spanning the full x-range so renderItem is always called.
63
+ // ECharts filters a data item only when ALL its x-dimensions are on the same side
64
+ // of the visible window. With dims 0=0 and 1=lastBar the item always straddles
65
+ // the viewport, so renderItem fires exactly once regardless of scroll position.
66
+ // Dims 2/3 are yMin/yMax for axis scaling.
67
+ const totalBars = (context.candlestickData?.length || 0) + offset;
68
+ const lastBarIndex = Math.max(0, totalBars - 1);
69
+
70
+ return {
71
+ name: seriesName,
72
+ type: 'custom',
73
+ xAxisIndex,
74
+ yAxisIndex,
75
+ renderItem: (params: any, api: any) => {
76
+ const children: any[] = [];
77
+
78
+ for (const bx of boxObjects) {
79
+ if (bx._deleted) continue;
80
+
81
+ const xOff = (bx.xloc === 'bar_index' || bx.xloc === 'bi') ? offset : 0;
82
+ const pTopLeft = api.coord([bx.left + xOff, bx.top]);
83
+ const pBottomRight = api.coord([bx.right + xOff, bx.bottom]);
84
+
85
+ let x = pTopLeft[0];
86
+ let y = pTopLeft[1];
87
+ let w = pBottomRight[0] - pTopLeft[0];
88
+ let h = pBottomRight[1] - pTopLeft[1];
89
+
90
+ // Handle extend (horizontal borders)
91
+ const extend = bx.extend || 'none';
92
+ if (extend !== 'none') {
93
+ const cs = params.coordSys;
94
+ if (extend === 'left' || extend === 'both') {
95
+ x = cs.x;
96
+ w = (extend === 'both') ? cs.width : (pBottomRight[0] - cs.x);
97
+ }
98
+ if (extend === 'right' || extend === 'both') {
99
+ if (extend === 'right') {
100
+ w = cs.x + cs.width - pTopLeft[0];
101
+ }
102
+ }
103
+ }
104
+
105
+ // Background fill rect
106
+ const bgColor = normalizeColor(bx.bgcolor) || '#2962ff';
107
+ children.push({
108
+ type: 'rect',
109
+ shape: { x, y, width: w, height: h },
110
+ style: { fill: bgColor },
111
+ });
112
+
113
+ // Border rect (on top of fill)
114
+ const borderColor = normalizeColor(bx.border_color) || '#2962ff';
115
+ const borderWidth = bx.border_width ?? 1;
116
+ if (borderWidth > 0) {
117
+ children.push({
118
+ type: 'rect',
119
+ shape: { x, y, width: w, height: h },
120
+ style: {
121
+ fill: 'none',
122
+ stroke: borderColor,
123
+ lineWidth: borderWidth,
124
+ lineDash: this.getDashPattern(bx.border_style),
125
+ },
126
+ });
127
+ }
128
+
129
+ // Text inside box
130
+ if (bx.text) {
131
+ const textX = this.getTextX(x, w, bx.text_halign);
132
+ const textY = this.getTextY(y, h, bx.text_valign);
133
+ children.push({
134
+ type: 'text',
135
+ style: {
136
+ x: textX,
137
+ y: textY,
138
+ text: bx.text,
139
+ fill: normalizeColor(bx.text_color) || '#000000',
140
+ fontSize: this.getSizePixels(bx.text_size),
141
+ fontFamily: bx.text_font_family === 'monospace' ? 'monospace' : 'sans-serif',
142
+ fontWeight: (bx.text_formatting === 'format_bold') ? 'bold' : 'normal',
143
+ fontStyle: (bx.text_formatting === 'format_italic') ? 'italic' : 'normal',
144
+ textAlign: this.mapHAlign(bx.text_halign),
145
+ textVerticalAlign: this.mapVAlign(bx.text_valign),
146
+ },
147
+ });
148
+ }
149
+ }
150
+
151
+ return { type: 'group', children };
152
+ },
153
+ data: [[0, lastBarIndex, yMin, yMax]],
154
+ clip: true,
155
+ encode: { x: [0, 1], y: [2, 3] },
156
+ z: 14,
157
+ silent: true,
158
+ emphasis: { disabled: true },
159
+ };
160
+ }
161
+
162
+ private getDashPattern(style: string): number[] | undefined {
163
+ switch (style) {
164
+ case 'style_dotted':
165
+ return [2, 2];
166
+ case 'style_dashed':
167
+ return [6, 4];
168
+ default:
169
+ return undefined;
170
+ }
171
+ }
172
+
173
+ private getSizePixels(size: string | number): number {
174
+ if (typeof size === 'number' && size > 0) return size;
175
+ switch (size) {
176
+ case 'auto':
177
+ case 'size.auto':
178
+ return 12;
179
+ case 'tiny':
180
+ case 'size.tiny':
181
+ return 8;
182
+ case 'small':
183
+ case 'size.small':
184
+ return 10;
185
+ case 'normal':
186
+ case 'size.normal':
187
+ return 14;
188
+ case 'large':
189
+ case 'size.large':
190
+ return 20;
191
+ case 'huge':
192
+ case 'size.huge':
193
+ return 36;
194
+ default:
195
+ return 12;
196
+ }
197
+ }
198
+
199
+ private mapHAlign(align: string): string {
200
+ switch (align) {
201
+ case 'left':
202
+ case 'text.align_left':
203
+ return 'left';
204
+ case 'right':
205
+ case 'text.align_right':
206
+ return 'right';
207
+ case 'center':
208
+ case 'text.align_center':
209
+ default:
210
+ return 'center';
211
+ }
212
+ }
213
+
214
+ private mapVAlign(align: string): string {
215
+ switch (align) {
216
+ case 'top':
217
+ case 'text.align_top':
218
+ return 'top';
219
+ case 'bottom':
220
+ case 'text.align_bottom':
221
+ return 'bottom';
222
+ case 'center':
223
+ case 'text.align_center':
224
+ default:
225
+ return 'middle';
226
+ }
227
+ }
228
+
229
+ private getTextX(x: number, w: number, halign: string): number {
230
+ switch (halign) {
231
+ case 'left':
232
+ case 'text.align_left':
233
+ return x + 4;
234
+ case 'right':
235
+ case 'text.align_right':
236
+ return x + w - 4;
237
+ case 'center':
238
+ case 'text.align_center':
239
+ default:
240
+ return x + w / 2;
241
+ }
242
+ }
243
+
244
+ private getTextY(y: number, h: number, valign: string): number {
245
+ switch (valign) {
246
+ case 'top':
247
+ case 'text.align_top':
248
+ return y + 4;
249
+ case 'bottom':
250
+ case 'text.align_bottom':
251
+ return y + h - 4;
252
+ case 'center':
253
+ case 'text.align_center':
254
+ default:
255
+ return y + h / 2;
256
+ }
257
+ }
258
+ }