Test Failed
Pull Request — master (#28)
by
unknown
05:54 queued 02:40
created

HillChart.render   A

Complexity

Conditions 2

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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