Test Failed
Pull Request — master (#28)
by
unknown
03:57 queued 29s
created

HillChart.renderGroup   F

Complexity

Conditions 13

Size

Total Lines 136
Code Lines 106

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 13
eloc 106
dl 0
loc 136
rs 2.94
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like HillChart.renderGroup often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
import { Selection } from 'd3-selection';
2
import EventEmitter from 'event-emitter-es6';
3
4
import {
5
  select,
6
  event,
7
  scaleLinear,
8
  axisBottom,
9
  line,
10
  drag,
11
  range,
12
} from './d3';
13
import {
14
  hillFn,
15
  hillFnInverse,
16
  textOutRange,
17
  calculateTextPositionForX,
18
  calculateTextMarginForY,
19
  uId,
20
} from './helpers';
21
import './styles.css';
22
import { Config, Data, DataPoint, IHillChartClass } from './types';
23
24
const DEFAULT_SIZE = 10;
25
const DEFAULT_Y = 0;
26
27
const defaults: Config = {
28
  target: 'svg',
29
  width: 900,
30
  height: 300,
31
  preview: false,
32
  darkMode: false,
33
  backgroundColor: 'transparent',
34
  footerText: {
35
    show: true,
36
    fontSize: 0.75,
37
  },
38
  margin: {
39
    top: 20,
40
    right: 20,
41
    bottom: 40,
42
    left: 20,
43
  },
44
};
45
46
export default class HillChart extends EventEmitter implements IHillChartClass {
47
  data;
48
49
  target;
50
51
  width;
52
53
  height;
54
55
  preview;
56
57
  darkMode;
58
59
  backgroundColor;
60
61
  footerText;
62
63
  margin;
64
65
  chartWidth = 0;
66
67
  chartHeight = 0;
68
69
  colorScheme: IHillChartClass['colorScheme'] = 'hill-chart-light';
70
71
  svg: IHillChartClass['svg'];
72
73
  xScale: IHillChartClass['xScale'] = scaleLinear();
74
75
  yScale: IHillChartClass['yScale'] = scaleLinear();
76
77
  bottomLine: IHillChartClass['bottomLine'] = axisBottom(this.xScale);
78
79
  mainLineCurvePoints: IHillChartClass['mainLineCurvePoints'] = [];
80
81
  line: IHillChartClass['line'] = line<Pick<DataPoint, 'x' | 'y'>>().x(0).y(0);
82
83
  constructor(data: Data, config: Config) {
84
    super();
85
86
    this.data = data;
87
88
    this.target = config.target || defaults.target;
89
    this.width = config.width || defaults.width;
90
    this.height = config.height || defaults.height;
91
    this.preview = config.preview || defaults.preview;
92
    this.darkMode = config.darkMode || defaults.darkMode;
93
    // TODO: remove support for undefined
94
    this.backgroundColor =
95
      'backgroundColor' in config
96
        ? config.backgroundColor
97
        : defaults.backgroundColor;
98
    this.footerText = config.footerText || defaults.footerText;
99
    this.margin = config.margin || defaults.margin;
100
101
    this.init();
102
  }
103
104
  init() {
105
    const { width, height, margin, target } = this;
106
107
    // Calculate real chart dimensions without the margins
108
    this.chartWidth = width - margin.left - margin.right;
109
    this.chartHeight = height - margin.top - margin.bottom;
110
111
    // Render the svg and center chart according to margins
112
    this.colorScheme = this.darkMode ? 'hill-chart-dark' : 'hill-chart-light';
113
    const defaultBg = this.darkMode ? '#2f3437' : '#ffffff';
114
    const bgColor = this.backgroundColor;
115
    const useDefaultBg = bgColor === true || bgColor === undefined;
116
    const useTransparentBg = this.backgroundColor === false;
117
    const suppliedBgColor = useDefaultBg ? defaultBg : this.backgroundColor;
118
    this.backgroundColor = useTransparentBg ? 'transparent' : suppliedBgColor;
119
120
    this.svg = select<SVGGElement, DataPoint>(target)
121
      .attr('class', this.colorScheme)
122
      .attr('width', width)
123
      .attr('height', height)
124
      .attr(
125
        'style',
126
        `stroke-width: 0; background-color: ${this.backgroundColor};`
127
      )
128
      .append('g')
129
      .attr('transform', `translate(${margin.left}, ${margin.top})`);
130
131
    // Set X and Y axis scale values, it used to determine the center of the chart
132
    // when calling this.xScale(50), it also flip the y axis to start from the
133
    // lowest point and scale up like claiming a hill from the ground.
134
    this.xScale = scaleLinear().domain([0, 100]).range([0, this.chartWidth]);
135
    this.yScale = scaleLinear().domain([0, 100]).range([this.chartHeight, 0]);
136
137
    // Normalize data on the y axis
138
    this.normalizeData();
139
  }
140
141
  normalizeData() {
142
    this.data = this.data.map((point) => {
143
      return {
144
        id: point.id ? point.id : uId(),
145
        color: point.color,
146
        description: point.description,
147
        link: point.link,
148
        x: point.x ? point.x : 0,
149
        y: point.y ? point.y : hillFn(point.x ? point.x : 0),
150
        size: point.size ? point.size : DEFAULT_SIZE,
151
      };
152
    });
153
  }
154
155
  // Replace the data points
156
  replaceData(data: Partial<DataPoint>[]) {
157
    // Update and normalize the data
158
    Object.assign(this, { data });
159
    this.normalizeData();
160
  }
161
162
  // Replace the data points, and re-render the group
163
  replaceAndUpdate(data: Data) {
164
    // Update and normalize the data
165
    this.replaceData(data);
166
167
    // Remove the existing points
168
    this.svg?.selectAll('.hill-chart-group').remove();
169
170
    // Render group of points
171
    this.renderGroup();
172
  }
173
174
  undraggablePoint() {
175
    return this.svg
176
      ?.selectAll('.hill-chart-group')
177
      .data(this.data)
178
      .enter()
179
      .append('a')
180
      .attr('href', (data) => {
181
        return data.link ? data.link : '#';
182
      })
183
      .append('g')
184
      .attr('class', 'hill-chart-group')
185
      .style('cursor', 'pointer')
186
      .attr('transform', (data) => {
187
        data.x = this.xScale(data.x);
188
        data.y = this.yScale(data.y || DEFAULT_Y);
189
        return `translate(${data.x}, ${data.y})`;
190
      });
191
  }
192
193
  render() {
194
    // Render the horizontal bottom line on X axis
195
    this.renderBottomLine(5);
196
197
    // Render the main curve line
198
    this.renderMainCurve();
199
200
    // Render the line in the middle
201
    this.renderMiddleLine();
202
203
    if (this.footerText.show) {
204
      // Render the text on the footer
205
      this.renderFooterText();
206
    }
207
208
    // Render the group on the chart
209
    this.renderGroup();
210
  }
211
212
  renderGroup() {
213
    // Handle dragging
214
    const dragPoint = drag<SVGGElement, DataPoint>().on('drag', (data) => {
215
      let { x } = event;
216
217
      // Check point movement, preventing it from wondering outside the main curve
218
      if (x < 0) {
219
        x = 0;
220
        this.emit('home', {
221
          ...data,
222
          y: hillFnInverse(this.yScale.invert(data.y || DEFAULT_Y)),
223
        });
224
      } else if (x > this.chartWidth) {
225
        x = this.chartWidth;
226
        this.emit('end', {
227
          ...data,
228
          x: this.xScale.invert(this.chartWidth),
229
          y: hillFnInverse(this.yScale.invert(data.y || DEFAULT_Y)),
230
        });
231
      }
232
233
      // Convert current point coordinates back to the original
234
      // between 0 and 100 to set it in the data attribute
235
      const invertedX = this.xScale.invert(x);
236
237
      data.x = x;
238
239
      data.y = this.yScale(hillFn(invertedX));
240
241
      const invertedY = hillFnInverse(this.yScale.invert(data.y));
242
243
      const newInvertedCoordinates = {
244
        x: invertedX,
245
        y: invertedY,
246
      };
247
248
      // click event
249
      select(this.target).on('click', () => {
250
        this.emit('pointClick', { ...data, ...newInvertedCoordinates });
251
      });
252
253
      if (!this.preview) {
254
        const selectedPoint = select<SVGGElement, { size: number }>(
255
          this.target
256
        ).attr('transform', `translate(${data.x}, ${data.y})`);
257
        selectedPoint
258
          .select('text')
259
          .style('text-anchor', () => {
260
            if (textOutRange(invertedX)) {
261
              return 'end';
262
            }
263
            return 'start';
264
          })
265
          .attr('x', (point) =>
266
            calculateTextPositionForX(point.size, invertedX)
267
          );
268
269
        this.emit('move', invertedX, invertedY);
270
      }
271
    });
272
273
    dragPoint.on('end', (data: DataPoint) => {
274
      if (this.preview) {
275
        return;
276
      }
277
278
      let { x } = event;
279
280
      // Check point movement, preventing it from wondering outside the main curve
281
      if (x < 0) {
282
        x = 0;
283
      } else if (x > this.chartWidth) {
284
        x = this.chartWidth;
285
      }
286
287
      // Convert current point coordinates back to the original
288
      const invertedX = this.xScale.invert(x);
289
      data.y = this.yScale(hillFn(invertedX));
290
      const invertedY = hillFnInverse(this.yScale.invert(data.y));
291
292
      const newInvertedCoordinates = {
293
        x: invertedX,
294
        y: invertedY,
295
      };
296
297
      this.emit('moved', { ...data, ...newInvertedCoordinates });
298
    });
299
300
    let group:
301
      | Selection<SVGGElement, DataPoint, SVGGElement, unknown>
302
      | undefined;
303
304
    if (this.preview) {
305
      group = this.undraggablePoint();
306
    } else {
307
      // Create group consisted of a circle and a description text, where
308
      // the data attributes determine the position of them on the curve
309
      group = this.svg
310
        ?.selectAll('.hill-chart-group')
311
        .data(this.data)
312
        .enter()
313
        .append('g')
314
        .attr('class', 'hill-chart-group')
315
        .attr('transform', (data) => {
316
          data.x = this.xScale(data.x);
317
          data.y = this.yScale(data.y || DEFAULT_Y);
318
          return `translate(${data.x}, ${data.y})`;
319
        })
320
        .call(dragPoint);
321
    }
322
323
    group
324
      ?.append('circle')
325
      .attr('class', 'hill-chart-circle')
326
      .attr('fill', (data) => data.color)
327
      .attr('cx', 0)
328
      .attr('cy', 0)
329
      .attr('r', (data) => data.size || DEFAULT_SIZE);
330
331
    group
332
      ?.append('text')
333
      .text((data) => data.description)
334
      .attr('x', (data) =>
335
        calculateTextPositionForX(
336
          data.size || DEFAULT_SIZE,
337
          this.xScale.invert(data.x)
338
        )
339
      )
340
      .style('text-anchor', (data) => {
341
        if (textOutRange(this.xScale.invert(data.x))) {
342
          return 'end';
343
        }
344
        return 'start';
345
      })
346
      .attr('y', calculateTextMarginForY());
347
  }
348
349
  renderMainCurve() {
350
    // Generate the main line curve points
351
    this.mainLineCurvePoints = range(0, 100, 0.1).map((i) => ({
352
      x: i,
353
      y: hillFn(i),
354
    }));
355
356
    // Map main line curve points to <svg> d attribute
357
    this.line = line<Pick<DataPoint, 'x' | 'y'>>()
358
      .x((d) => this.xScale(d.x))
359
      .y((d) => this.yScale(d.y || DEFAULT_Y));
360
361
    // Render the actual main line curve
362
    this.svg
363
      ?.append('path')
364
      .attr('class', 'chart-hill-main-curve')
365
      .datum(this.mainLineCurvePoints)
366
      .attr('d', this.line);
367
  }
368
369
  renderBottomLine(marginTop = 5) {
370
    // Generate the horizontal bottom line on the X axis
371
    this.bottomLine = axisBottom(this.xScale).ticks(0).tickSize(0);
372
373
    // Render the acutal svg
374
    this.svg
375
      ?.append('g')
376
      .attr('class', 'hill-chart-bottom-line')
377
      .attr('transform', `translate(0, ${this.chartHeight + marginTop})`)
378
      .call(this.bottomLine);
379
  }
380
381
  renderMiddleLine() {
382
    this.svg
383
      ?.append('line')
384
      .attr('class', 'hill-chart-middle-line')
385
      .attr('y1', this.yScale(0))
386
      .attr('y2', this.yScale(100))
387
      .attr('x2', this.xScale(50))
388
      .attr('x1', this.xScale(50));
389
  }
390
391
  renderFooterText() {
392
    this.svg
393
      ?.append('text')
394
      .attr('class', 'hill-chart-text')
395
      .text('Figuring things out')
396
      .style('font-size', `${this.footerText.fontSize}rem`)
397
      .attr('x', this.xScale(25))
398
      .attr('y', this.chartHeight + 30);
399
400
    this.svg
401
      ?.append('text')
402
      .attr('class', 'hill-chart-text')
403
      .text('Making it happen')
404
      .style('font-size', `${this.footerText.fontSize}rem`)
405
      .attr('x', this.xScale(75))
406
      .attr('y', this.chartHeight + 30);
407
  }
408
}
409