Passed
Pull Request — master (#28)
by
unknown
03:42
created

HillChart.renderMainCurve   A

Complexity

Conditions 1

Size

Total Lines 19
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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