mintwaterfall 0.8.6
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/CHANGELOG.md +223 -0
- package/CONTRIBUTING.md +199 -0
- package/README.md +363 -0
- package/dist/index.d.ts +149 -0
- package/dist/mintwaterfall.cjs.js +7978 -0
- package/dist/mintwaterfall.esm.js +7907 -0
- package/dist/mintwaterfall.min.js +7 -0
- package/dist/mintwaterfall.umd.js +7978 -0
- package/index.d.ts +149 -0
- package/package.json +126 -0
- package/src/enterprise/enterprise-core.js +0 -0
- package/src/enterprise/enterprise-feature-template.js +0 -0
- package/src/enterprise/feature-registry.js +0 -0
- package/src/enterprise/features/breakdown.js +0 -0
- package/src/features/breakdown.js +0 -0
- package/src/features/conditional-formatting.js +0 -0
- package/src/index.js +111 -0
- package/src/mintwaterfall-accessibility.ts +680 -0
- package/src/mintwaterfall-advanced-data.ts +1034 -0
- package/src/mintwaterfall-advanced-interactions.ts +649 -0
- package/src/mintwaterfall-advanced-performance.ts +582 -0
- package/src/mintwaterfall-animations.ts +595 -0
- package/src/mintwaterfall-brush.ts +471 -0
- package/src/mintwaterfall-chart-core.ts +296 -0
- package/src/mintwaterfall-chart.ts +1915 -0
- package/src/mintwaterfall-data.ts +1100 -0
- package/src/mintwaterfall-export.ts +475 -0
- package/src/mintwaterfall-hierarchical-layouts.ts +724 -0
- package/src/mintwaterfall-layouts.ts +647 -0
- package/src/mintwaterfall-performance.ts +573 -0
- package/src/mintwaterfall-scales.ts +437 -0
- package/src/mintwaterfall-shapes.ts +385 -0
- package/src/mintwaterfall-statistics.ts +821 -0
- package/src/mintwaterfall-themes.ts +391 -0
- package/src/mintwaterfall-tooltip.ts +450 -0
- package/src/mintwaterfall-zoom.ts +399 -0
- package/src/types/js-modules.d.ts +25 -0
- package/src/utils/compatibility-layer.js +0 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
// MintWaterfall Brush Selection System - TypeScript Version
|
|
2
|
+
// Provides interactive data selection with visual feedback and full type safety
|
|
3
|
+
|
|
4
|
+
import * as d3 from 'd3';
|
|
5
|
+
|
|
6
|
+
// Type definitions for brush system
|
|
7
|
+
export interface BrushConfig {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
extent: [[number, number], [number, number]];
|
|
10
|
+
handleSize: number;
|
|
11
|
+
filter: ((event: any) => boolean) | null;
|
|
12
|
+
touchable: boolean;
|
|
13
|
+
keyModifiers: boolean;
|
|
14
|
+
selection: {
|
|
15
|
+
fill: string;
|
|
16
|
+
fillOpacity: number;
|
|
17
|
+
stroke: string;
|
|
18
|
+
strokeWidth: number;
|
|
19
|
+
strokeDasharray: string | null;
|
|
20
|
+
};
|
|
21
|
+
handles: {
|
|
22
|
+
fill: string;
|
|
23
|
+
stroke: string;
|
|
24
|
+
strokeWidth: number;
|
|
25
|
+
size: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BrushSelection {
|
|
30
|
+
x: [number, number];
|
|
31
|
+
y: [number, number];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface BrushEventData {
|
|
35
|
+
selection: BrushSelection | null;
|
|
36
|
+
sourceEvent: any;
|
|
37
|
+
type: 'start' | 'brush' | 'end';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DataPoint {
|
|
41
|
+
x: number;
|
|
42
|
+
y: number;
|
|
43
|
+
[key: string]: any;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BrushSystem {
|
|
47
|
+
enable(): BrushSystem;
|
|
48
|
+
disable(): BrushSystem;
|
|
49
|
+
attach(container: d3.Selection<SVGGElement, any, any, any>): BrushSystem;
|
|
50
|
+
detach(): BrushSystem;
|
|
51
|
+
clear(): BrushSystem;
|
|
52
|
+
getSelection(): BrushSelection | null;
|
|
53
|
+
setSelection(selection: BrushSelection | null): BrushSystem;
|
|
54
|
+
getSelectedData(): DataPoint[];
|
|
55
|
+
setData(data: DataPoint[]): BrushSystem;
|
|
56
|
+
configure(newConfig: Partial<BrushConfig>): BrushSystem;
|
|
57
|
+
setExtent(extent: [[number, number], [number, number]]): BrushSystem;
|
|
58
|
+
isEnabled(): boolean;
|
|
59
|
+
on(type: string, callback: (event: BrushEventData) => void): BrushSystem;
|
|
60
|
+
off(type: string, callback?: (event: BrushEventData) => void): BrushSystem;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type BrushEventType = 'brushstart' | 'brush' | 'brushend' | 'clear';
|
|
64
|
+
|
|
65
|
+
export function createBrushSystem(): BrushSystem {
|
|
66
|
+
|
|
67
|
+
// Brush configuration
|
|
68
|
+
const config: BrushConfig = {
|
|
69
|
+
enabled: true,
|
|
70
|
+
extent: [[0, 0], [800, 400]],
|
|
71
|
+
handleSize: 6,
|
|
72
|
+
filter: null, // Use D3 default filter
|
|
73
|
+
touchable: true,
|
|
74
|
+
keyModifiers: true,
|
|
75
|
+
selection: {
|
|
76
|
+
fill: '#007acc',
|
|
77
|
+
fillOpacity: 0.3,
|
|
78
|
+
stroke: '#007acc',
|
|
79
|
+
strokeWidth: 1,
|
|
80
|
+
strokeDasharray: null
|
|
81
|
+
},
|
|
82
|
+
handles: {
|
|
83
|
+
fill: '#fff',
|
|
84
|
+
stroke: '#007acc',
|
|
85
|
+
strokeWidth: 1,
|
|
86
|
+
size: 6
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
let brushBehavior: d3.BrushBehavior<any> | null = null;
|
|
91
|
+
let currentSelection: BrushSelection | null = null;
|
|
92
|
+
let brushContainer: d3.Selection<SVGGElement, any, any, any> | null = null;
|
|
93
|
+
let dataPoints: DataPoint[] = [];
|
|
94
|
+
|
|
95
|
+
// Event listeners
|
|
96
|
+
const listeners = d3.dispatch("brushstart", "brush", "brushend", "clear");
|
|
97
|
+
|
|
98
|
+
function createBrushBehavior(): d3.BrushBehavior<any> {
|
|
99
|
+
if (brushBehavior) return brushBehavior;
|
|
100
|
+
|
|
101
|
+
brushBehavior = d3.brush<any>()
|
|
102
|
+
.extent(config.extent)
|
|
103
|
+
.handleSize(config.handleSize)
|
|
104
|
+
.touchable(config.touchable)
|
|
105
|
+
.keyModifiers(config.keyModifiers)
|
|
106
|
+
.on("start", handleBrushStart)
|
|
107
|
+
.on("brush", handleBrush)
|
|
108
|
+
.on("end", handleBrushEnd);
|
|
109
|
+
|
|
110
|
+
// Set filter if provided
|
|
111
|
+
if (config.filter) {
|
|
112
|
+
brushBehavior.filter(config.filter);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return brushBehavior;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function handleBrushStart(event: d3.D3BrushEvent<any>): void {
|
|
119
|
+
const selection = convertD3Selection(event.selection as [[number, number], [number, number]] | null);
|
|
120
|
+
currentSelection = selection;
|
|
121
|
+
|
|
122
|
+
const eventData: BrushEventData = {
|
|
123
|
+
selection,
|
|
124
|
+
sourceEvent: event.sourceEvent,
|
|
125
|
+
type: 'start'
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
listeners.call("brushstart", undefined, eventData);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function handleBrush(event: d3.D3BrushEvent<any>): void {
|
|
132
|
+
const selection = convertD3Selection(event.selection as [[number, number], [number, number]] | null);
|
|
133
|
+
currentSelection = selection;
|
|
134
|
+
|
|
135
|
+
const eventData: BrushEventData = {
|
|
136
|
+
selection,
|
|
137
|
+
sourceEvent: event.sourceEvent,
|
|
138
|
+
type: 'brush'
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
listeners.call("brush", undefined, eventData);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function handleBrushEnd(event: d3.D3BrushEvent<any>): void {
|
|
145
|
+
const selection = convertD3Selection(event.selection as [[number, number], [number, number]] | null);
|
|
146
|
+
currentSelection = selection;
|
|
147
|
+
|
|
148
|
+
const eventData: BrushEventData = {
|
|
149
|
+
selection,
|
|
150
|
+
sourceEvent: event.sourceEvent,
|
|
151
|
+
type: 'end'
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
listeners.call("brushend", undefined, eventData);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function convertD3Selection(d3Selection: [[number, number], [number, number]] | null): BrushSelection | null {
|
|
158
|
+
if (!d3Selection) return null;
|
|
159
|
+
|
|
160
|
+
const [[x0, y0], [x1, y1]] = d3Selection;
|
|
161
|
+
return {
|
|
162
|
+
x: [Math.min(x0, x1), Math.max(x0, x1)],
|
|
163
|
+
y: [Math.min(y0, y1), Math.max(y0, y1)]
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function convertToBrushSelection(selection: BrushSelection): [[number, number], [number, number]] {
|
|
168
|
+
return [
|
|
169
|
+
[selection.x[0], selection.y[0]],
|
|
170
|
+
[selection.x[1], selection.y[1]]
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function applyBrushStyles(): void {
|
|
175
|
+
if (!brushContainer) return;
|
|
176
|
+
|
|
177
|
+
// Style the selection area
|
|
178
|
+
brushContainer.selectAll(".selection")
|
|
179
|
+
.style("fill", config.selection.fill)
|
|
180
|
+
.style("fill-opacity", config.selection.fillOpacity)
|
|
181
|
+
.style("stroke", config.selection.stroke)
|
|
182
|
+
.style("stroke-width", config.selection.strokeWidth);
|
|
183
|
+
|
|
184
|
+
// Apply stroke dash array only if it's not null
|
|
185
|
+
if (config.selection.strokeDasharray) {
|
|
186
|
+
brushContainer.selectAll(".selection")
|
|
187
|
+
.style("stroke-dasharray", config.selection.strokeDasharray);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Style the handles
|
|
191
|
+
brushContainer.selectAll(".handle")
|
|
192
|
+
.style("fill", config.handles.fill)
|
|
193
|
+
.style("stroke", config.handles.stroke)
|
|
194
|
+
.style("stroke-width", config.handles.strokeWidth);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function filterDataBySelection(selection: BrushSelection): DataPoint[] {
|
|
198
|
+
if (!selection || dataPoints.length === 0) return [];
|
|
199
|
+
|
|
200
|
+
const [x0, x1] = selection.x;
|
|
201
|
+
const [y0, y1] = selection.y;
|
|
202
|
+
|
|
203
|
+
return dataPoints.filter(point => {
|
|
204
|
+
return point.x >= x0 && point.x <= x1 &&
|
|
205
|
+
point.y >= y0 && point.y <= y1;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Enable brush
|
|
210
|
+
function enable(): BrushSystem {
|
|
211
|
+
config.enabled = true;
|
|
212
|
+
if (brushBehavior && brushContainer) {
|
|
213
|
+
brushContainer.call(brushBehavior);
|
|
214
|
+
applyBrushStyles();
|
|
215
|
+
}
|
|
216
|
+
return brushSystem;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Disable brush
|
|
220
|
+
function disable(): BrushSystem {
|
|
221
|
+
config.enabled = false;
|
|
222
|
+
if (brushContainer) {
|
|
223
|
+
brushContainer.on(".brush", null);
|
|
224
|
+
}
|
|
225
|
+
return brushSystem;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Attach brush to container
|
|
229
|
+
function attach(container: d3.Selection<SVGGElement, any, any, any>): BrushSystem {
|
|
230
|
+
brushContainer = container;
|
|
231
|
+
|
|
232
|
+
if (config.enabled) {
|
|
233
|
+
const behavior = createBrushBehavior();
|
|
234
|
+
container.call(behavior);
|
|
235
|
+
applyBrushStyles();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return brushSystem;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Detach brush from container
|
|
242
|
+
function detach(): BrushSystem {
|
|
243
|
+
if (brushContainer) {
|
|
244
|
+
brushContainer.on(".brush", null);
|
|
245
|
+
brushContainer.selectAll(".brush").remove();
|
|
246
|
+
brushContainer = null;
|
|
247
|
+
}
|
|
248
|
+
return brushSystem;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Clear current selection
|
|
252
|
+
function clear(): BrushSystem {
|
|
253
|
+
if (brushContainer && brushBehavior) {
|
|
254
|
+
brushContainer.call(brushBehavior.clear);
|
|
255
|
+
currentSelection = null;
|
|
256
|
+
listeners.call("clear", undefined, {
|
|
257
|
+
selection: null,
|
|
258
|
+
sourceEvent: null,
|
|
259
|
+
type: 'end'
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return brushSystem;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Get current selection
|
|
266
|
+
function getSelection(): BrushSelection | null {
|
|
267
|
+
return currentSelection;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Set selection programmatically
|
|
271
|
+
function setSelection(selection: BrushSelection | null): BrushSystem {
|
|
272
|
+
if (brushContainer && brushBehavior) {
|
|
273
|
+
if (selection) {
|
|
274
|
+
const d3Selection = convertToBrushSelection(selection);
|
|
275
|
+
brushContainer.call(brushBehavior.move, d3Selection);
|
|
276
|
+
} else {
|
|
277
|
+
brushContainer.call(brushBehavior.clear);
|
|
278
|
+
}
|
|
279
|
+
currentSelection = selection;
|
|
280
|
+
}
|
|
281
|
+
return brushSystem;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Get data points within current selection
|
|
285
|
+
function getSelectedData(): DataPoint[] {
|
|
286
|
+
if (!currentSelection) return [];
|
|
287
|
+
return filterDataBySelection(currentSelection);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Set data points for selection filtering
|
|
291
|
+
function setData(data: DataPoint[]): BrushSystem {
|
|
292
|
+
dataPoints = [...data]; // Create a copy to avoid external mutations
|
|
293
|
+
return brushSystem;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Configure brush system
|
|
297
|
+
function configure(newConfig: Partial<BrushConfig>): BrushSystem {
|
|
298
|
+
const oldExtent = config.extent;
|
|
299
|
+
Object.assign(config, newConfig);
|
|
300
|
+
|
|
301
|
+
// Update brush behavior if it exists
|
|
302
|
+
if (brushBehavior) {
|
|
303
|
+
if (newConfig.extent && newConfig.extent !== oldExtent) {
|
|
304
|
+
brushBehavior.extent(newConfig.extent);
|
|
305
|
+
}
|
|
306
|
+
if (newConfig.handleSize !== undefined) {
|
|
307
|
+
brushBehavior.handleSize(newConfig.handleSize);
|
|
308
|
+
}
|
|
309
|
+
if (newConfig.touchable !== undefined) {
|
|
310
|
+
brushBehavior.touchable(newConfig.touchable);
|
|
311
|
+
}
|
|
312
|
+
if (newConfig.keyModifiers !== undefined) {
|
|
313
|
+
brushBehavior.keyModifiers(newConfig.keyModifiers);
|
|
314
|
+
}
|
|
315
|
+
if (newConfig.filter !== undefined) {
|
|
316
|
+
if (newConfig.filter) {
|
|
317
|
+
brushBehavior.filter(newConfig.filter);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Apply visual style updates
|
|
323
|
+
if (brushContainer) {
|
|
324
|
+
applyBrushStyles();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return brushSystem;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Set brush extent
|
|
331
|
+
function setExtent(extent: [[number, number], [number, number]]): BrushSystem {
|
|
332
|
+
config.extent = extent;
|
|
333
|
+
if (brushBehavior) {
|
|
334
|
+
brushBehavior.extent(extent);
|
|
335
|
+
}
|
|
336
|
+
return brushSystem;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Check if brush is enabled
|
|
340
|
+
function isEnabled(): boolean {
|
|
341
|
+
return config.enabled;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Add event listener
|
|
345
|
+
function on(type: string, callback: (event: BrushEventData) => void): BrushSystem {
|
|
346
|
+
(listeners as any).on(type, callback);
|
|
347
|
+
return brushSystem;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Remove event listener
|
|
351
|
+
function off(type: string, callback?: (event: BrushEventData) => void): BrushSystem {
|
|
352
|
+
(listeners as any).on(type, null);
|
|
353
|
+
return brushSystem;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const brushSystem: BrushSystem = {
|
|
357
|
+
enable,
|
|
358
|
+
disable,
|
|
359
|
+
attach,
|
|
360
|
+
detach,
|
|
361
|
+
clear,
|
|
362
|
+
getSelection,
|
|
363
|
+
setSelection,
|
|
364
|
+
getSelectedData,
|
|
365
|
+
setData,
|
|
366
|
+
configure,
|
|
367
|
+
setExtent,
|
|
368
|
+
isEnabled,
|
|
369
|
+
on,
|
|
370
|
+
off
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
return brushSystem;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Factory function that returns the expected test API
|
|
377
|
+
export function createBrushSystemFactory() {
|
|
378
|
+
function createBrush(options: { type?: 'x' | 'y' | 'xy' } = {}): any {
|
|
379
|
+
const { type = 'xy' } = options;
|
|
380
|
+
|
|
381
|
+
switch (type) {
|
|
382
|
+
case 'x':
|
|
383
|
+
return d3.brushX();
|
|
384
|
+
case 'y':
|
|
385
|
+
return d3.brushY();
|
|
386
|
+
case 'xy':
|
|
387
|
+
default:
|
|
388
|
+
return d3.brush();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function filterDataByBrush(data: any[], selection: [number, number], scale: any): any[] {
|
|
393
|
+
if (!selection || !scale) return data;
|
|
394
|
+
|
|
395
|
+
const [start, end] = selection;
|
|
396
|
+
return data.filter(d => {
|
|
397
|
+
const value = scale(d.x || d.label || d.value);
|
|
398
|
+
return value >= start && value <= end;
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function getSelectedIndices(data: any[], selection: [number, number], scale: any): number[] {
|
|
403
|
+
if (!selection || !scale) return [];
|
|
404
|
+
|
|
405
|
+
const [start, end] = selection;
|
|
406
|
+
const indices: number[] = [];
|
|
407
|
+
|
|
408
|
+
data.forEach((d, i) => {
|
|
409
|
+
const value = scale(d.x || d.label || d.value);
|
|
410
|
+
if (value >= start && value <= end) {
|
|
411
|
+
indices.push(i);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
return indices;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const selectionUtils = {
|
|
419
|
+
createSelectionSummary(selectedData: any[]): any {
|
|
420
|
+
if (!selectedData || selectedData.length === 0) {
|
|
421
|
+
return {
|
|
422
|
+
count: 0,
|
|
423
|
+
sum: 0,
|
|
424
|
+
average: 0,
|
|
425
|
+
min: 0,
|
|
426
|
+
max: 0
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const values = selectedData.map(d => d.cumulativeTotal || d.value || d.y || 0);
|
|
431
|
+
const sum = values.reduce((a, b) => a + b, 0);
|
|
432
|
+
const min = Math.min(...values);
|
|
433
|
+
const max = Math.max(...values);
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
count: selectedData.length,
|
|
437
|
+
sum,
|
|
438
|
+
average: sum / selectedData.length,
|
|
439
|
+
min,
|
|
440
|
+
max,
|
|
441
|
+
extent: [min, max]
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// Event handler methods
|
|
447
|
+
let startHandler: Function | null = null;
|
|
448
|
+
let moveHandler: Function | null = null;
|
|
449
|
+
let endHandler: Function | null = null;
|
|
450
|
+
|
|
451
|
+
const brushFactory = {
|
|
452
|
+
createBrush,
|
|
453
|
+
filterDataByBrush,
|
|
454
|
+
getSelectedIndices,
|
|
455
|
+
selectionUtils,
|
|
456
|
+
onStart(handler: Function) {
|
|
457
|
+
startHandler = handler;
|
|
458
|
+
return brushFactory;
|
|
459
|
+
},
|
|
460
|
+
onMove(handler: Function) {
|
|
461
|
+
moveHandler = handler;
|
|
462
|
+
return brushFactory;
|
|
463
|
+
},
|
|
464
|
+
onEnd(handler: Function) {
|
|
465
|
+
endHandler = handler;
|
|
466
|
+
return brushFactory;
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
return brushFactory;
|
|
471
|
+
}
|