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

HillChart.renderFooterText   A

Complexity

Conditions 1

Size

Total Lines 17
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 16
dl 0
loc 17
rs 9.6
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 {
23
  Config,
24
  ConfigInternal,
25
  Data,
26
  DataPointInternal,
27
  IHillChartClass,
28
} from './types';
29
30
const DEFAULT_SIZE = 10;
31
32
const defaults: ConfigInternal = {
33
  target: 'svg',
34
  width: 900,
35
  height: 300,
36
  preview: false,
37
  darkMode: false,
38
  backgroundColor: 'transparent',
39
  footerText: {
40
    show: true,
41
    fontSize: 0.75,
42
  },
43
  margin: {
44
    top: 20,
45
    right: 20,
46
    bottom: 40,
47
    left: 20,
48
  },
49
};
50
51
export default class HillChart extends EventEmitter implements IHillChartClass {
52
  /**
53
   * ### Public Variables ###
54
   */
55
56
  data: IHillChartClass['data'] = [];
57
58
  target = defaults.target;
59
60
  width = defaults.width;
61
62
  height = defaults.height;
63
64
  preview = defaults.preview;
65
66
  darkMode = defaults.darkMode;
67
68
  backgroundColor = defaults.backgroundColor;
69
70
  footerText = defaults.footerText;
71
72
  margin = defaults.margin;
73
74
  /**
75
   * ### Private Variables ###
76
   */
77
78
  chartWidth = 0;
79
80
  chartHeight = 0;
81
82
  colorScheme: IHillChartClass['colorScheme'] = 'hill-chart-light';
83
84
  svg: IHillChartClass['svg'] = select<SVGGElement, DataPointInternal>('svg');
85
86
  xScale: IHillChartClass['xScale'] = scaleLinear();
87
88
  yScale: IHillChartClass['yScale'] = scaleLinear();
89
90
  bottomLine: IHillChartClass['bottomLine'] = axisBottom(this.xScale);
91
92
  mainLineCurvePoints: IHillChartClass['mainLineCurvePoints'] = [];
93
94
  line: IHillChartClass['line'] = line<Pick<DataPointInternal, 'x' | 'y'>>()
95
    .x(0)
96
    .y(0);
97
98
  constructor(data: Data, config: Config) {
99
    super();
100
    Object.assign(this, defaults, { data }, config);
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, DataPointInternal>(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<DataPointInternal>[]) {
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);
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
    const self = this;
214
215
    // Handle dragging
216
    const dragPoint = drag<SVGGElement, DataPointInternal>()
217
      .on('drag', function (data) {
218
        let { x } = event;
219
220
        // Check point movement, preventing it from wondering outside the main curve
221
        if (!x || x < 0) {
222
          x = 0;
223
          self.emit('home', {
224
            ...data,
225
            y: hillFnInverse(self.yScale.invert(data.y)),
226
          });
227
        } else if (x > self.chartWidth) {
228
          x = self.chartWidth;
229
          self.emit('end', {
230
            ...data,
231
            x: self.xScale.invert(self.chartWidth),
232
            y: hillFnInverse(self.yScale.invert(data.y)),
233
          });
234
        }
235
236
        // Convert current point coordinates back to the original
237
        // between 0 and 100 to set it in the data attribute
238
        const invertedX = self.xScale.invert(x);
239
240
        data.x = x;
241
242
        data.y = self.yScale(hillFn(invertedX));
243
244
        const invertedY = hillFnInverse(self.yScale.invert(data.y));
245
246
        const newInvertedCoordinates = {
247
          x: invertedX,
248
          y: invertedY,
249
        };
250
251
        // click event
252
        select<SVGGElement, DataPointInternal>(this).on('click', () => {
253
          self.emit('pointClick', { ...data, ...newInvertedCoordinates });
254
        });
255
256
        if (!self.preview) {
257
          const selectedPoint = select<SVGGElement, DataPointInternal>(
258
            this
259
          ).attr('transform', `translate(${data.x}, ${data.y})`);
260
          selectedPoint
261
            .select('text')
262
            .style('text-anchor', () => {
263
              if (textOutRange(invertedX)) {
264
                return 'end';
265
              }
266
              return 'start';
267
            })
268
            .attr('x', (point) =>
269
              calculateTextPositionForX(point.size, invertedX)
270
            );
271
272
          self.emit('move', invertedX, invertedY);
273
        }
274
      })
275
      .on('end', (data) => {
276
        if (this.preview) {
277
          return;
278
        }
279
280
        let { x } = event;
281
282
        // Check point movement, preventing it from wondering outside the main curve
283
        if (!x || x < 0) {
284
          x = 0;
285
        } else if (x > this.chartWidth) {
286
          x = this.chartWidth;
287
        }
288
289
        // Convert current point coordinates back to the original
290
        const invertedX = this.xScale.invert(x);
291
        data.y = this.yScale(hillFn(invertedX));
292
        const invertedY = hillFnInverse(this.yScale.invert(data.y));
293
294
        const newInvertedCoordinates = {
295
          x: invertedX,
296
          y: invertedY,
297
        };
298
299
        this.emit('moved', { ...data, ...newInvertedCoordinates });
300
      });
301
302
    let group:
303
      | Selection<SVGGElement, DataPointInternal, SVGGElement, unknown>
304
      | undefined;
305
306
    if (this.preview) {
307
      group = this.undraggablePoint();
308
    } else {
309
      // Create group consisted of a circle and a description text, where
310
      // the data attributes determine the position of them on the curve
311
      group = this.svg
312
        .selectAll('.hill-chart-group')
313
        .data(this.data)
314
        .enter()
315
        .append('g')
316
        .attr('class', 'hill-chart-group')
317
        .attr('transform', (data) => {
318
          data.x = this.xScale(data.x);
319
          data.y = this.yScale(data.y);
320
          return `translate(${data.x}, ${data.y})`;
321
        })
322
        .call(dragPoint);
323
    }
324
325
    group
326
      .append('circle')
327
      .attr('class', 'hill-chart-circle')
328
      .attr('fill', (data) => data.color)
329
      .attr('cx', 0)
330
      .attr('cy', 0)
331
      .attr('r', (data) => data.size || DEFAULT_SIZE);
332
333
    group
334
      .append('text')
335
      .text((data) => data.description)
336
      .attr('x', (data) =>
337
        calculateTextPositionForX(
338
          data.size || DEFAULT_SIZE,
339
          this.xScale.invert(data.x)
340
        )
341
      )
342
      .style('text-anchor', (data) => {
343
        if (textOutRange(this.xScale.invert(data.x))) {
344
          return 'end';
345
        }
346
        return 'start';
347
      })
348
      .attr('y', calculateTextMarginForY());
349
  }
350
351
  renderMainCurve() {
352
    // Generate the main line curve points
353
    this.mainLineCurvePoints = range(0, 100, 0.1).map((i) => ({
354
      x: i,
355
      y: hillFn(i),
356
    }));
357
358
    // Map main line curve points to <svg> d attribute
359
    this.line = line<Pick<DataPointInternal, 'x' | 'y'>>()
360
      .x((d) => this.xScale(d.x))
361
      .y((d) => this.yScale(d.y));
362
363
    // Render the actual main line curve
364
    this.svg
365
      .append('path')
366
      .attr('class', 'chart-hill-main-curve')
367
      .datum(this.mainLineCurvePoints)
368
      .attr('d', this.line);
369
  }
370
371
  renderBottomLine(marginTop = 5) {
372
    // Generate the horizontal bottom line on the X axis
373
    this.bottomLine = axisBottom(this.xScale).ticks(0).tickSize(0);
374
375
    // Render the acutal svg
376
    this.svg
377
      .append('g')
378
      .attr('class', 'hill-chart-bottom-line')
379
      .attr('transform', `translate(0, ${this.chartHeight + marginTop})`)
380
      .call(this.bottomLine);
381
  }
382
383
  renderMiddleLine() {
384
    this.svg
385
      .append('line')
386
      .attr('class', 'hill-chart-middle-line')
387
      .attr('y1', this.yScale(0))
388
      .attr('y2', this.yScale(100))
389
      .attr('x2', this.xScale(50))
390
      .attr('x1', this.xScale(50));
391
  }
392
393
  renderFooterText() {
394
    this.svg
395
      .append('text')
396
      .attr('class', 'hill-chart-text')
397
      .text('Figuring things out')
398
      .style('font-size', `${this.footerText.fontSize}rem`)
399
      .attr('x', this.xScale(25))
400
      .attr('y', this.chartHeight + 30);
401
402
    this.svg
403
      .append('text')
404
      .attr('class', 'hill-chart-text')
405
      .text('Making it happen')
406
      .style('font-size', `${this.footerText.fontSize}rem`)
407
      .attr('x', this.xScale(75))
408
      .attr('y', this.chartHeight + 30);
409
  }
410
}
411