@qfo/qfchart 0.6.7 → 0.6.8
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.
- package/dist/index.d.ts +1 -1
- package/dist/qfchart.min.browser.js +16 -16
- package/dist/qfchart.min.es.js +16 -16
- package/package.json +81 -81
- package/src/components/SeriesBuilder.ts +251 -250
- package/src/components/SeriesRendererFactory.ts +4 -0
- package/src/components/renderers/DrawingLineRenderer.ts +188 -0
- package/src/components/renderers/FillRenderer.ts +99 -99
- package/src/components/renderers/LabelRenderer.ts +78 -35
- package/src/components/renderers/LinefillRenderer.ts +167 -0
- package/src/components/renderers/SeriesRenderer.ts +21 -20
- package/src/types.ts +2 -1
- package/src/utils/ShapeUtils.ts +148 -140
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
2
|
+
import { ColorUtils } from '../../utils/ColorUtils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renderer for Pine Script linefill.* drawing objects.
|
|
6
|
+
* Each linefill fills the area between two line objects as a polygon.
|
|
7
|
+
*
|
|
8
|
+
* Style name: 'linefill'
|
|
9
|
+
*/
|
|
10
|
+
export class LinefillRenderer implements SeriesRenderer {
|
|
11
|
+
render(context: RenderContext): any {
|
|
12
|
+
const { seriesName, xAxisIndex, yAxisIndex, dataArray, dataIndexOffset } = context;
|
|
13
|
+
const offset = dataIndexOffset || 0;
|
|
14
|
+
|
|
15
|
+
// Collect all non-deleted linefill objects from the sparse dataArray.
|
|
16
|
+
// Same aggregation pattern as DrawingLineRenderer — objects are stored
|
|
17
|
+
// as an array in a single data entry.
|
|
18
|
+
const fillObjects: any[] = [];
|
|
19
|
+
const fillData: number[][] = [];
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < dataArray.length; i++) {
|
|
22
|
+
const val = dataArray[i];
|
|
23
|
+
if (!val) continue;
|
|
24
|
+
|
|
25
|
+
const items = Array.isArray(val) ? val : [val];
|
|
26
|
+
for (const lf of items) {
|
|
27
|
+
if (!lf || typeof lf !== 'object' || lf._deleted) continue;
|
|
28
|
+
|
|
29
|
+
const line1 = lf.line1;
|
|
30
|
+
const line2 = lf.line2;
|
|
31
|
+
if (!line1 || !line2 || line1._deleted || line2._deleted) continue;
|
|
32
|
+
|
|
33
|
+
fillObjects.push(lf);
|
|
34
|
+
|
|
35
|
+
// Store all 8 coordinates for the two lines
|
|
36
|
+
const xOff1 = line1.xloc === 'bar_index' ? offset : 0;
|
|
37
|
+
const xOff2 = line2.xloc === 'bar_index' ? offset : 0;
|
|
38
|
+
fillData.push([
|
|
39
|
+
line1.x1 + xOff1, line1.y1,
|
|
40
|
+
line1.x2 + xOff1, line1.y2,
|
|
41
|
+
line2.x1 + xOff2, line2.y1,
|
|
42
|
+
line2.x2 + xOff2, line2.y2,
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (fillData.length === 0) {
|
|
48
|
+
return { name: seriesName, type: 'custom', xAxisIndex, yAxisIndex, data: [], silent: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
name: seriesName,
|
|
53
|
+
type: 'custom',
|
|
54
|
+
xAxisIndex,
|
|
55
|
+
yAxisIndex,
|
|
56
|
+
renderItem: (params: any, api: any) => {
|
|
57
|
+
const idx = params.dataIndex;
|
|
58
|
+
const lf = fillObjects[idx];
|
|
59
|
+
if (!lf || lf._deleted) return;
|
|
60
|
+
|
|
61
|
+
const line1 = lf.line1;
|
|
62
|
+
const line2 = lf.line2;
|
|
63
|
+
if (!line1 || !line2 || line1._deleted || line2._deleted) return;
|
|
64
|
+
|
|
65
|
+
// Get data values: line1 start, line1 end, line2 start, line2 end
|
|
66
|
+
const l1x1 = api.value(0);
|
|
67
|
+
const l1y1 = api.value(1);
|
|
68
|
+
const l1x2 = api.value(2);
|
|
69
|
+
const l1y2 = api.value(3);
|
|
70
|
+
const l2x1 = api.value(4);
|
|
71
|
+
const l2y1 = api.value(5);
|
|
72
|
+
const l2x2 = api.value(6);
|
|
73
|
+
const l2y2 = api.value(7);
|
|
74
|
+
|
|
75
|
+
// Convert to pixel coordinates
|
|
76
|
+
let p1Start = api.coord([l1x1, l1y1]);
|
|
77
|
+
let p1End = api.coord([l1x2, l1y2]);
|
|
78
|
+
let p2Start = api.coord([l2x1, l2y1]);
|
|
79
|
+
let p2End = api.coord([l2x2, l2y2]);
|
|
80
|
+
|
|
81
|
+
// Handle line extensions — if lines are extended, extend the fill too
|
|
82
|
+
const extend1 = line1.extend || 'none';
|
|
83
|
+
const extend2 = line2.extend || 'none';
|
|
84
|
+
if (extend1 !== 'none' || extend2 !== 'none') {
|
|
85
|
+
const cs = params.coordSys;
|
|
86
|
+
const left = cs.x;
|
|
87
|
+
const right = cs.x + cs.width;
|
|
88
|
+
const top = cs.y;
|
|
89
|
+
const bottom = cs.y + cs.height;
|
|
90
|
+
|
|
91
|
+
if (extend1 !== 'none') {
|
|
92
|
+
[p1Start, p1End] = this.extendLine(p1Start, p1End, extend1, left, right, top, bottom);
|
|
93
|
+
}
|
|
94
|
+
if (extend2 !== 'none') {
|
|
95
|
+
[p2Start, p2End] = this.extendLine(p2Start, p2End, extend2, left, right, top, bottom);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Parse color
|
|
100
|
+
const { color: fillColor, opacity: fillOpacity } = ColorUtils.parseColor(lf.color || 'rgba(128, 128, 128, 0.2)');
|
|
101
|
+
|
|
102
|
+
// Create a polygon: line1.start → line1.end → line2.end → line2.start
|
|
103
|
+
return {
|
|
104
|
+
type: 'polygon',
|
|
105
|
+
shape: {
|
|
106
|
+
points: [
|
|
107
|
+
p1Start,
|
|
108
|
+
p1End,
|
|
109
|
+
p2End,
|
|
110
|
+
p2Start,
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
style: {
|
|
114
|
+
fill: fillColor,
|
|
115
|
+
opacity: fillOpacity,
|
|
116
|
+
},
|
|
117
|
+
silent: true,
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
data: fillData,
|
|
121
|
+
z: 10, // Behind lines (z=15) but above other elements
|
|
122
|
+
silent: true,
|
|
123
|
+
emphasis: { disabled: true },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private extendLine(
|
|
128
|
+
p1: number[],
|
|
129
|
+
p2: number[],
|
|
130
|
+
extend: string,
|
|
131
|
+
left: number,
|
|
132
|
+
right: number,
|
|
133
|
+
top: number,
|
|
134
|
+
bottom: number,
|
|
135
|
+
): [number[], number[]] {
|
|
136
|
+
const dx = p2[0] - p1[0];
|
|
137
|
+
const dy = p2[1] - p1[1];
|
|
138
|
+
|
|
139
|
+
if (dx === 0 && dy === 0) return [p1, p2];
|
|
140
|
+
|
|
141
|
+
const extendPoint = (origin: number[], dir: number[]): number[] => {
|
|
142
|
+
let tMax = Infinity;
|
|
143
|
+
if (dir[0] !== 0) {
|
|
144
|
+
const tx = dir[0] > 0 ? (right - origin[0]) / dir[0] : (left - origin[0]) / dir[0];
|
|
145
|
+
tMax = Math.min(tMax, tx);
|
|
146
|
+
}
|
|
147
|
+
if (dir[1] !== 0) {
|
|
148
|
+
const ty = dir[1] > 0 ? (bottom - origin[1]) / dir[1] : (top - origin[1]) / dir[1];
|
|
149
|
+
tMax = Math.min(tMax, ty);
|
|
150
|
+
}
|
|
151
|
+
if (!isFinite(tMax)) tMax = 0;
|
|
152
|
+
return [origin[0] + tMax * dir[0], origin[1] + tMax * dir[1]];
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
let newP1 = p1;
|
|
156
|
+
let newP2 = p2;
|
|
157
|
+
|
|
158
|
+
if (extend === 'right' || extend === 'both') {
|
|
159
|
+
newP2 = extendPoint(p1, [dx, dy]);
|
|
160
|
+
}
|
|
161
|
+
if (extend === 'left' || extend === 'both') {
|
|
162
|
+
newP1 = extendPoint(p2, [-dx, -dy]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return [newP1, newP2];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import { IndicatorPlot, OHLCV } from '../../types';
|
|
2
|
-
|
|
3
|
-
export interface RenderContext {
|
|
4
|
-
seriesName: string;
|
|
5
|
-
xAxisIndex: number;
|
|
6
|
-
yAxisIndex: number;
|
|
7
|
-
dataArray: any[];
|
|
8
|
-
colorArray: any[];
|
|
9
|
-
optionsArray: any[];
|
|
10
|
-
plotOptions: any;
|
|
11
|
-
candlestickData?: OHLCV[]; // For shape positioning
|
|
12
|
-
plotDataArrays?: Map<string, number[]>; // For fill plots
|
|
13
|
-
indicatorId?: string;
|
|
14
|
-
plotName?: string;
|
|
15
|
-
indicator?: any; // Reference to parent indicator object if needed
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
1
|
+
import { IndicatorPlot, OHLCV } from '../../types';
|
|
2
|
+
|
|
3
|
+
export interface RenderContext {
|
|
4
|
+
seriesName: string;
|
|
5
|
+
xAxisIndex: number;
|
|
6
|
+
yAxisIndex: number;
|
|
7
|
+
dataArray: any[];
|
|
8
|
+
colorArray: any[];
|
|
9
|
+
optionsArray: any[];
|
|
10
|
+
plotOptions: any;
|
|
11
|
+
candlestickData?: OHLCV[]; // For shape positioning
|
|
12
|
+
plotDataArrays?: Map<string, number[]>; // For fill plots
|
|
13
|
+
indicatorId?: string;
|
|
14
|
+
plotName?: string;
|
|
15
|
+
indicator?: any; // Reference to parent indicator object if needed
|
|
16
|
+
dataIndexOffset?: number; // Padding offset for converting bar_index to ECharts index
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SeriesRenderer {
|
|
20
|
+
render(context: RenderContext): any;
|
|
21
|
+
}
|
package/src/types.ts
CHANGED
package/src/utils/ShapeUtils.ts
CHANGED
|
@@ -1,140 +1,148 @@
|
|
|
1
|
-
export class ShapeUtils {
|
|
2
|
-
public static getShapeSymbol(shape: string): string {
|
|
3
|
-
// SVG Paths need to be:
|
|
4
|
-
// 1. Valid SVG path data strings
|
|
5
|
-
// 2. Ideally centered around the origin or a standard box (e.g., 0 0 24 24)
|
|
6
|
-
// 3. ECharts path:// format expects just the path data usually, but complex shapes might need 'image://' or better paths.
|
|
7
|
-
// For simple shapes, standard ECharts symbols or simple paths work.
|
|
8
|
-
|
|
9
|
-
switch (shape) {
|
|
10
|
-
case 'arrowdown':
|
|
11
|
-
// Blocky arrow down
|
|
12
|
-
return 'path://M12 24l-12-12h8v-12h8v12h8z';
|
|
13
|
-
|
|
14
|
-
case 'arrowup':
|
|
15
|
-
// Blocky arrow up
|
|
16
|
-
return 'path://M12 0l12 12h-8v12h-8v-12h-8z';
|
|
17
|
-
|
|
18
|
-
case 'circle':
|
|
19
|
-
return 'circle';
|
|
20
|
-
|
|
21
|
-
case 'cross':
|
|
22
|
-
// Plus sign (+)
|
|
23
|
-
return 'path://M11 2h2v9h9v2h-9v9h-2v-9h-9v-2h9z';
|
|
24
|
-
|
|
25
|
-
case 'diamond':
|
|
26
|
-
return 'diamond'; // Built-in
|
|
27
|
-
|
|
28
|
-
case 'flag':
|
|
29
|
-
// Flag on a pole
|
|
30
|
-
return 'path://M6 2v20h2v-8h12l-2-6 2-6h-12z';
|
|
31
|
-
|
|
32
|
-
case 'labeldown':
|
|
33
|
-
// Bubble pointing down:
|
|
34
|
-
return 'path://
|
|
35
|
-
|
|
36
|
-
case '
|
|
37
|
-
// Bubble
|
|
38
|
-
return 'path://
|
|
39
|
-
|
|
40
|
-
case '
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return '
|
|
50
|
-
|
|
51
|
-
case '
|
|
52
|
-
//
|
|
53
|
-
return 'path://
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
case '
|
|
123
|
-
// Shape
|
|
124
|
-
return { position: '
|
|
125
|
-
|
|
126
|
-
case '
|
|
127
|
-
// Shape
|
|
128
|
-
return { position: '
|
|
129
|
-
|
|
130
|
-
case '
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
1
|
+
export class ShapeUtils {
|
|
2
|
+
public static getShapeSymbol(shape: string): string {
|
|
3
|
+
// SVG Paths need to be:
|
|
4
|
+
// 1. Valid SVG path data strings
|
|
5
|
+
// 2. Ideally centered around the origin or a standard box (e.g., 0 0 24 24)
|
|
6
|
+
// 3. ECharts path:// format expects just the path data usually, but complex shapes might need 'image://' or better paths.
|
|
7
|
+
// For simple shapes, standard ECharts symbols or simple paths work.
|
|
8
|
+
|
|
9
|
+
switch (shape) {
|
|
10
|
+
case 'arrowdown':
|
|
11
|
+
// Blocky arrow down
|
|
12
|
+
return 'path://M12 24l-12-12h8v-12h8v12h8z';
|
|
13
|
+
|
|
14
|
+
case 'arrowup':
|
|
15
|
+
// Blocky arrow up
|
|
16
|
+
return 'path://M12 0l12 12h-8v12h-8v-12h-8z';
|
|
17
|
+
|
|
18
|
+
case 'circle':
|
|
19
|
+
return 'circle';
|
|
20
|
+
|
|
21
|
+
case 'cross':
|
|
22
|
+
// Plus sign (+)
|
|
23
|
+
return 'path://M11 2h2v9h9v2h-9v9h-2v-9h-9v-2h9z';
|
|
24
|
+
|
|
25
|
+
case 'diamond':
|
|
26
|
+
return 'diamond'; // Built-in
|
|
27
|
+
|
|
28
|
+
case 'flag':
|
|
29
|
+
// Flag on a pole
|
|
30
|
+
return 'path://M6 2v20h2v-8h12l-2-6 2-6h-12z';
|
|
31
|
+
|
|
32
|
+
case 'labeldown':
|
|
33
|
+
// Bubble pointing down: rect with small triangle at bottom center
|
|
34
|
+
return 'path://M2 1h20a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-8l-2 3-2-3h-8a1 1 0 0 1-1-1v-14a1 1 0 0 1 1-1z';
|
|
35
|
+
|
|
36
|
+
case 'labelleft':
|
|
37
|
+
// Bubble with small pointer on the left side (pointing left)
|
|
38
|
+
return 'path://M0 10l3-3v-5a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-18a1 1 0 0 1-1-1v-5z';
|
|
39
|
+
|
|
40
|
+
case 'labelright':
|
|
41
|
+
// Bubble with small pointer on the right side (pointing right)
|
|
42
|
+
return 'path://M24 10l-3-3v-5a1 1 0 0 0-1-1h-18a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-5z';
|
|
43
|
+
|
|
44
|
+
case 'labelup':
|
|
45
|
+
// Bubble pointing up: small triangle at top, rect below
|
|
46
|
+
return 'path://M12 1l2 3h8a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-20a1 1 0 0 1-1-1v-14a1 1 0 0 1 1-1h8z';
|
|
47
|
+
|
|
48
|
+
case 'square':
|
|
49
|
+
return 'rect';
|
|
50
|
+
|
|
51
|
+
case 'triangledown':
|
|
52
|
+
// Pointing down
|
|
53
|
+
return 'path://M12 21l-10-18h20z';
|
|
54
|
+
|
|
55
|
+
case 'triangleup':
|
|
56
|
+
// Pointing up
|
|
57
|
+
return 'triangle'; // Built-in is pointing up
|
|
58
|
+
|
|
59
|
+
case 'xcross':
|
|
60
|
+
// 'X' shape
|
|
61
|
+
return 'path://M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z';
|
|
62
|
+
|
|
63
|
+
default:
|
|
64
|
+
return 'circle';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public static getShapeRotation(shape: string): number {
|
|
69
|
+
// With custom paths defined above, we might not need rotation unless we reuse shapes.
|
|
70
|
+
// Built-in triangle is UP.
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public static getShapeSize(size: string, width?: number, height?: number): number | number[] {
|
|
75
|
+
// If both width and height are specified, use them directly
|
|
76
|
+
if (width !== undefined && height !== undefined) {
|
|
77
|
+
return [width, height];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Base size from the size parameter
|
|
81
|
+
let baseSize: number;
|
|
82
|
+
switch (size) {
|
|
83
|
+
case 'tiny':
|
|
84
|
+
baseSize = 8;
|
|
85
|
+
break;
|
|
86
|
+
case 'small':
|
|
87
|
+
baseSize = 12;
|
|
88
|
+
break;
|
|
89
|
+
case 'normal':
|
|
90
|
+
case 'auto':
|
|
91
|
+
baseSize = 16;
|
|
92
|
+
break;
|
|
93
|
+
case 'large':
|
|
94
|
+
baseSize = 24;
|
|
95
|
+
break;
|
|
96
|
+
case 'huge':
|
|
97
|
+
baseSize = 32;
|
|
98
|
+
break;
|
|
99
|
+
default:
|
|
100
|
+
baseSize = 16;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// If only width is specified, preserve aspect ratio (assume square default)
|
|
104
|
+
if (width !== undefined) {
|
|
105
|
+
return [width, width];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// If only height is specified, preserve aspect ratio (assume square default)
|
|
109
|
+
if (height !== undefined) {
|
|
110
|
+
return [height, height];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Default uniform size
|
|
114
|
+
return baseSize;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Helper to determine label position and distance relative to shape BASED ON LOCATION
|
|
118
|
+
public static getLabelConfig(shape: string, location: string): { position: string; distance: number } {
|
|
119
|
+
// Text position should be determined by location, not shape direction
|
|
120
|
+
|
|
121
|
+
switch (location) {
|
|
122
|
+
case 'abovebar':
|
|
123
|
+
// Shape is above the candle, text should be above the shape
|
|
124
|
+
return { position: 'top', distance: 5 };
|
|
125
|
+
|
|
126
|
+
case 'belowbar':
|
|
127
|
+
// Shape is below the candle, text should be below the shape
|
|
128
|
+
return { position: 'bottom', distance: 5 };
|
|
129
|
+
|
|
130
|
+
case 'top':
|
|
131
|
+
// Shape at top of chart, text below it
|
|
132
|
+
return { position: 'bottom', distance: 5 };
|
|
133
|
+
|
|
134
|
+
case 'bottom':
|
|
135
|
+
// Shape at bottom of chart, text above it
|
|
136
|
+
return { position: 'top', distance: 5 };
|
|
137
|
+
|
|
138
|
+
case 'absolute':
|
|
139
|
+
default:
|
|
140
|
+
// For labelup/down, text is INSIDE the shape
|
|
141
|
+
if (shape === 'labelup' || shape === 'labeldown') {
|
|
142
|
+
return { position: 'inside', distance: 0 };
|
|
143
|
+
}
|
|
144
|
+
// For other shapes, text above by default
|
|
145
|
+
return { position: 'top', distance: 5 };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|