├── src
├── circos
│ ├── scales.js
│ ├── logger.js
│ ├── behaviors
│ │ ├── tooltip.css
│ │ └── tooltip.js
│ ├── clipboard.js
│ ├── layout
│ │ ├── conf.js
│ │ ├── index.js
│ │ └── render.js
│ ├── tracks
│ │ ├── Heatmap.js
│ │ ├── Highlight.js
│ │ ├── Heatmap.test.js
│ │ ├── Histogram.js
│ │ ├── Line.test.js
│ │ ├── Text.js
│ │ ├── Scatter.js
│ │ ├── Line.js
│ │ ├── Chords.js
│ │ ├── Stack.js
│ │ └── Track.js
│ ├── utils.js
│ ├── configs.js
│ ├── colors.test.js
│ ├── render.js
│ ├── config-utils.js
│ ├── axes.js
│ ├── colors.js
│ ├── index.js
│ ├── data-parser.test.js
│ ├── utils.test.js
│ ├── data-parser.js
│ └── axes.test.js
├── main.js
├── index.html
├── vendor.js
├── helper.js
├── showcase
│ ├── simple-circos.js
│ └── simple-dot.js
└── App.vue
├── demo.gif
├── postcss.config.js
├── Caddyfile
├── .gitignore
├── Dockerfile
├── mock
├── helpers.js
└── server.js
├── package.json
├── README.md
└── webpack.config.js
/src/circos/scales.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/circos/logger.js:
--------------------------------------------------------------------------------
1 | export default console
2 |
--------------------------------------------------------------------------------
/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pingcap/tidb-vision/HEAD/demo.gif
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [require("autoprefixer")()]
3 | };
4 |
--------------------------------------------------------------------------------
/Caddyfile:
--------------------------------------------------------------------------------
1 | {$HOST}:{$PORT}
2 |
3 | proxy /pd PD_ENDPOINT {
4 | policy round_robin
5 | }
6 |
7 | log stdout
8 |
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules/
3 | dist/
4 | npm-debug.log
5 | .idea
6 | src/circos_raw
7 | logo.sketch
8 | TiDB Vision.svg
9 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import Vue from "vue";
2 | import App from "./App.vue";
3 |
4 | new Vue({
5 | el: "#app",
6 | render: h => h(App)
7 | });
8 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Pd Vis
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/circos/behaviors/tooltip.css:
--------------------------------------------------------------------------------
1 | div.circos-tooltip {
2 | position: absolute;
3 | text-align: center;
4 | padding: 5px 10px;
5 | background: #111111;
6 | color: white;
7 | border: 0px;
8 | pointer-events: none;
9 | z-index: 1000;
10 | }
11 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM abiosoft/caddy
2 |
3 | ADD dist /src
4 |
5 | ADD Caddyfile /etc/Caddyfile
6 |
7 | WORKDIR /src
8 |
9 | ENV PD_ENDPOINT=localhost:9000 REGION_BYTE_SIZE=100663296 PORT=8010 HOST=0.0.0.0
10 |
11 | EXPOSE 8010
12 |
13 | ENTRYPOINT ["/bin/sh", "-c", "sed -i -e \"s/PD_ENDPOINT/$PD_ENDPOINT/g\" /etc/Caddyfile;caddy --conf /etc/Caddyfile"]
--------------------------------------------------------------------------------
/src/circos/clipboard.js:
--------------------------------------------------------------------------------
1 | import clipboard from 'clipboard-js'
2 | import {select} from 'd3-selection'
3 |
4 | export const initClipboard = (container) => {
5 | const input = select(container)
6 | .append('input')
7 | .attr('class', 'circos-clipboard')
8 | .attr('type', 'hidden')
9 |
10 | select('body').on('keydown', () => {
11 | if (event.ctrlKey && event.code === 'KeyC') {
12 | clipboard.copy(input.attr('value'))
13 | }
14 | })
15 | return input
16 | }
17 |
--------------------------------------------------------------------------------
/src/vendor.js:
--------------------------------------------------------------------------------
1 | import Vue from "vue";
2 |
3 | const d3Base = require('d3')
4 | const annotation = require('d3-svg-annotation')
5 | const legend = require('d3-svg-legend')
6 | import Circos from './circos'
7 | import { interpolateYlGn, interpolateGreens } from 'd3-scale-chromatic'
8 | import _ from 'lodash'
9 | import axios from 'axios'
10 |
11 | let d3 = Object.assign(d3Base, annotation, legend,
12 | { interpolateYlGn, interpolateGreens}
13 | )
14 |
15 | window.d3 = d3
16 |
17 | export {
18 | d3, Circos, _, axios
19 | }
20 |
--------------------------------------------------------------------------------
/src/circos/behaviors/tooltip.js:
--------------------------------------------------------------------------------
1 | import {select, event} from 'd3-selection'
2 | import 'd3-transition'
3 |
4 | import './tooltip.css'
5 |
6 | export function registerTooltip (track, instance, element, trackParams) {
7 | track.dispatch.on('mouseover', (d) => {
8 | instance.tip
9 | .html(trackParams.tooltipContent(d))
10 | .transition()
11 | .style('opacity', 0.9)
12 | .style('left', (event.pageX) + 'px')
13 | .style('top', (event.pageY - 28) + 'px')
14 | })
15 |
16 | track.dispatch.on('mouseout', (d) => {
17 | instance.tip
18 | .transition()
19 | .duration(500)
20 | .style('opacity', 0)
21 | })
22 | }
23 |
--------------------------------------------------------------------------------
/src/circos/layout/conf.js:
--------------------------------------------------------------------------------
1 | export default {
2 | innerRadius: 250,
3 | outerRadius: 300,
4 | cornerRadius: 0,
5 | gap: 0.04, // in radian
6 | opacity: 1,
7 | labels: {
8 | position: 'center',
9 | display: true,
10 | size: 14,
11 | color: '#000',
12 | radialOffset: 20
13 | },
14 | ticks: {
15 | display: true,
16 | color: 'grey',
17 | spacing: 10000000,
18 | labels: true,
19 | labelSpacing: 10,
20 | labelSuffix: '',
21 | labelDenominator: 1,
22 | labelDisplay0: true,
23 | labelSize: 10,
24 | labelColor: '#000',
25 | labelFont: 'default',
26 | majorSpacing: 5,
27 | size: {
28 | minor: 2,
29 | major: 5
30 | }
31 | },
32 | onClick: null,
33 | onMouseOver: null,
34 | events: {},
35 | zIndex: 100
36 | }
37 |
--------------------------------------------------------------------------------
/src/helper.js:
--------------------------------------------------------------------------------
1 |
2 | function rgbToHex(color) {
3 | color = '' + color
4 | if (!color || color.indexOf('rgb') < 0) {
5 | return
6 | }
7 |
8 | if (color.charAt(0) == '#') {
9 | return color
10 | }
11 |
12 | var nums = /(.*?)rgb\((\d+),\s*(\d+),\s*(\d+)\)/i.exec(color),
13 | r = parseInt(nums[2], 10).toString(16),
14 | g = parseInt(nums[3], 10).toString(16),
15 | b = parseInt(nums[4], 10).toString(16)
16 |
17 | return (
18 | '#' +
19 | ((r.length == 1 ? '0' + r : r) +
20 | (g.length == 1 ? '0' + g : g) +
21 | (b.length == 1 ? '0' + b : b))
22 | )
23 | }
24 |
25 | function sampleValFn(p) {
26 | const v = Math.random()
27 | if (v < p) {
28 | return 2000
29 | }
30 | return 1000
31 | }
32 |
33 | function createHotpotEffect() {
34 | var i = document.createElementNS('http://www.w3.org/2000/svg', 'animate')
35 | i.setAttribute('class', 'hotspot-blink')
36 | i.setAttribute('values', '#800;#f00;#800;#800')
37 | i.setAttribute('dur', '0.8s')
38 | i.setAttribute('repeatCount', 'indefinite')
39 | i.setAttribute('attributeType', 'XML')
40 | i.setAttribute('attributeName', 'fill')
41 | return i
42 | }
43 |
44 |
45 | export {createHotpotEffect, sampleValFn, rgbToHex}
46 |
--------------------------------------------------------------------------------
/src/circos/tracks/Heatmap.js:
--------------------------------------------------------------------------------
1 | import Track from './Track'
2 | import {parseSpanValueData} from '../data-parser'
3 | import {arc} from 'd3-shape'
4 | import assign from 'lodash/assign'
5 | import {radial, values, common} from '../configs'
6 |
7 | const defaultConf = assign({
8 | color: {
9 | value: 'Spectral',
10 | iteratee: false
11 | },
12 | backgrounds: {
13 | value: [],
14 | iteratee: false
15 | }
16 | }, radial, values, common)
17 |
18 | export default class Heatmap extends Track {
19 | constructor (instance, conf, data) {
20 | super(instance, conf, defaultConf, data, parseSpanValueData)
21 | }
22 |
23 | renderDatum (parentElement, conf, layout) {
24 | return parentElement
25 | .selectAll('tile')
26 | .data((d) => d.values)
27 | .enter()
28 | .append('path')
29 | .attr('class', 'tile')
30 | .attr('opacity', conf.opacity)
31 | .attr('d', arc()
32 | .innerRadius(conf.innerRadius)
33 | .outerRadius(conf.outerRadius)
34 | .startAngle((d, i) => this.theta(d.start, layout.blocks[d.block_id]))
35 | .endAngle((d, i) => this.theta(d.end, layout.blocks[d.block_id]))
36 | )
37 | .attr('fill', conf.colorValue)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/circos/tracks/Highlight.js:
--------------------------------------------------------------------------------
1 | import Track from './Track'
2 | import {parseSpanStringData} from '../data-parser'
3 | import assign from 'lodash/assign'
4 | import {radial, common} from '../configs'
5 | import {arc} from 'd3-shape'
6 |
7 | const defaultConf = assign({
8 | color: {
9 | value: '#fd6a62',
10 | iteratee: true
11 | },
12 | strokeColor: {
13 | value: '#d3d3d3',
14 | iteratee: true
15 | },
16 | strokeWidth: {
17 | value: 0,
18 | iteratee: true
19 | }
20 | }, radial, common)
21 |
22 | export default class Highlight extends Track {
23 | constructor (instance, conf, data) {
24 | super(instance, conf, defaultConf, data, parseSpanStringData)
25 | }
26 |
27 | renderDatum (parentElement, conf, layout) {
28 | return parentElement.selectAll('tile')
29 | .data((d) => d.values)
30 | .enter().append('path')
31 | .attr('class', 'tile')
32 | .attr('d', arc()
33 | .innerRadius(conf.innerRadius)
34 | .outerRadius(conf.outerRadius)
35 | .startAngle((d, i) => this.theta(d.start, layout.blocks[d.block_id]))
36 | .endAngle((d, i) => this.theta(d.end, layout.blocks[d.block_id]))
37 | )
38 | .attr('fill', conf.colorValue)
39 | .attr('opacity', conf.opacity)
40 | .attr('stroke-width', conf.strokeWidth)
41 | .attr('stroke', conf.strokeColor)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/showcase/simple-circos.js:
--------------------------------------------------------------------------------
1 | var myCircos = new Circos({
2 | container: "#chart",
3 | width: 960,
4 | height: 960
5 | });
6 |
7 | var configuration = {
8 | innerRadius: 250,
9 | outerRadius: 300,
10 | cornerRadius: 10,
11 | gap: 0.04, // in radian
12 | labels: {
13 | display: true,
14 | position: "center",
15 | size: "14px",
16 | color: "#000000",
17 | radialOffset: 20
18 | },
19 | ticks: {
20 | display: true,
21 | color: "grey",
22 | spacing: 10000000,
23 | labels: true,
24 | labelSpacing: 10,
25 | labelSuffix: "Mb",
26 | labelDenominator: 1000000,
27 | labelDisplay0: true,
28 | labelSize: "10px",
29 | labelColor: "#000000",
30 | labelFont: "default",
31 | majorSpacing: 5,
32 | size: {
33 | minor: 2,
34 | major: 5
35 | }
36 | },
37 | events: {}
38 | };
39 |
40 | var data = [
41 | { len: 400, color: "#8dd3c7", label: "January", id: "january" },
42 | { len: 28, color: "#ffffb3", label: "February", id: "february" },
43 | { len: 31, color: "#bebada", label: "March", id: "march" },
44 | { len: 31, color: "#bc80bd", label: "October", id: "october" },
45 | { len: 30, color: "#ccebc5", label: "November", id: "november" },
46 | { len: 31, color: "#ffed6f", label: "December", id: "december" }
47 | ];
48 |
49 | myCircos.layout(data, configuration);
50 |
51 | myCircos.render();
52 |
--------------------------------------------------------------------------------
/mock/helpers.js:
--------------------------------------------------------------------------------
1 |
2 | const _merge = require('lodash.merge')
3 |
4 | function merge(def, over) {
5 | return _merge({}, def, over)
6 | }
7 | /*
8 | helper for easy usage
9 | field can be function, which will be call
10 | */
11 | function iterObj(obj) {
12 | const ret = Object.assign(Array.isArray(obj) ? [] : {}, obj)
13 | for (var key in obj) {
14 | if (obj[key] == null) continue
15 | if (typeof obj[key] === 'object') {
16 | ret[key] = iterObj(obj[key])
17 | }
18 | if (typeof obj[key] === 'function') {
19 | ret[key] = obj[key]()
20 | }
21 | }
22 | return ret
23 | }
24 |
25 | function jsonSend(data, code = 200, message = null) {
26 | return (req, res, next) => {
27 | res.send(iterObj(data))
28 | }
29 | }
30 |
31 | function variety(defVal, mapVals) {
32 | return (req, res, next) => {
33 | const type = req.query._type
34 | let _ret = type && mapVals[type] ? mapVals[type] : defVal
35 | if (!_ret.code) {
36 | // _ret = { data: _ret }
37 | _ret = {
38 | payload: _ret,
39 | }
40 | }
41 | // const ret = merge({ code: 200, message: null }, _ret)
42 | const ret = merge(
43 | {
44 | action: '',
45 | status_code: 200,
46 | message: null,
47 | },
48 | _ret
49 | )
50 | res.send(iterObj(ret))
51 | }
52 | }
53 |
54 | exports = module.exports = {
55 | merge, variety, iterObj, jsonSend
56 | }
57 |
--------------------------------------------------------------------------------
/src/circos/utils.js:
--------------------------------------------------------------------------------
1 | import sortBy from 'lodash/fp/sortBy'
2 | import flow from 'lodash/fp/flow'
3 | import concat from 'lodash/fp/concat'
4 | import filter from 'lodash/fp/filter'
5 | import first from 'lodash/fp/first'
6 | import reverse from 'lodash/fp/reverse'
7 | import {scaleLog, scaleLinear} from 'd3-scale'
8 |
9 | export function smartBorders (conf, layout, tracks) {
10 | const width = conf.defaultTrackWidth || 30
11 |
12 | const externalTrack = flow(
13 | filter('conf.outerRadius'),
14 | sortBy('conf.outerRadius'),
15 | reverse,
16 | first
17 | )(concat(tracks, layout))
18 |
19 | return ({
20 | in: externalTrack.conf.outerRadius,
21 | out: externalTrack.conf.outerRadius + width
22 | })
23 | }
24 |
25 | export function computeMinMax (conf, meta) {
26 | conf.cmin = conf.min === null ? meta.min : conf.min
27 | conf.cmax = conf.max === null ? meta.max : conf.max
28 | return conf
29 | }
30 |
31 | export function buildScale (
32 | min, max, height, logScale = false, logScaleBase = Math.E
33 | ) {
34 | if (logScale && min * max <= 0) {
35 | console.warn(`As log(0) = -∞, a log scale domain must be
36 | strictly-positive or strictly-negative. logscale ignored`
37 | )
38 | }
39 | const scale = (logScale && min * max > 0)
40 | ? scaleLog().base(logScaleBase) : scaleLinear()
41 |
42 | return scale
43 | .domain([min, max])
44 | .range([0, height])
45 | .clamp(true)
46 | }
47 |
--------------------------------------------------------------------------------
/src/circos/configs.js:
--------------------------------------------------------------------------------
1 | const axes = {
2 | axes: {
3 | value: [],
4 | iteratee: false
5 | },
6 | showAxesTooltip: {
7 | value: true,
8 | iteratee: false
9 | }
10 | }
11 |
12 | const palette = {
13 | colorPaletteSize: {
14 | value: 9,
15 | iteratee: false
16 | },
17 | colorPalette: {
18 | value: 'YlGnBu',
19 | iteratee: false
20 | },
21 | usePalette: {
22 | value: true,
23 | iteratee: false
24 | },
25 | colorPaletteReverse: {
26 | value: true,
27 | iteratee: false
28 | }
29 | }
30 |
31 | const radial = {
32 | innerRadius: {
33 | value: 0,
34 | iteratee: false
35 | },
36 | outerRadius: {
37 | value: 0,
38 | iteratee: false
39 | }
40 | }
41 |
42 | const values = {
43 | min: {
44 | value: null,
45 | iteratee: false
46 | },
47 | max: {
48 | value: null,
49 | iteratee: false
50 | },
51 | logScale: {
52 | value: false,
53 | iteratee: false
54 | },
55 | logScaleBase: {
56 | value: Math.E,
57 | iteratee: false
58 | }
59 | }
60 |
61 | const common = {
62 | zIndex: {
63 | value: false,
64 | iteratee: false
65 | },
66 | opacity: {
67 | value: 1,
68 | iteratee: true
69 | },
70 | tooltipContent: {
71 | value: null,
72 | iteratee: false
73 | },
74 | events: {
75 | value: {},
76 | iteratee: false
77 | }
78 | }
79 |
80 | export {
81 | axes,
82 | palette,
83 | radial,
84 | values,
85 | common
86 | }
87 |
--------------------------------------------------------------------------------
/src/circos/colors.test.js:
--------------------------------------------------------------------------------
1 | import { describe, it } from 'mocha'
2 | import { expect } from 'chai'
3 | import { buildColorValue } from './colors'
4 | import forEach from 'lodash/forEach'
5 |
6 | describe('colors', () => {
7 | describe('buildColorValue', () => {
8 | const greens0to10 = {
9 | 0: 'rgb(247, 252, 245)',
10 | 5: 'rgb(115, 195, 120)',
11 | 10: 'rgb(0, 68, 27)',
12 | 11: 'rgb(0, 68, 27)'
13 | }
14 |
15 | it('should return the color code if it\'s not a palette', () => {
16 | expect(buildColorValue('red')).to.equal('red')
17 | expect(buildColorValue('#d3d3d3')).to.equal('#d3d3d3')
18 | })
19 |
20 | it('should return the input if input is a function', () => {
21 | const colorValue = (d) => '#d3d3d3'
22 | expect(buildColorValue(colorValue)).to.equal(colorValue)
23 | })
24 |
25 | it('should return the expected scale if input is a palette', () => {
26 | const colorValue = buildColorValue('Greens', 0, 10)
27 | expect(colorValue).to.be.an.instanceOf(Function)
28 | forEach(greens0to10, (value, key) => {
29 | expect(colorValue({value: key})).to.equal(value)
30 | })
31 | })
32 | it('should reverse the palette if palette is prefixed by "-"', () => {
33 | const colorValue = buildColorValue('-Greens', 0, 10)
34 | expect(colorValue).to.be.an.instanceOf(Function)
35 | forEach(greens0to10, (value, key) => {
36 | expect(colorValue({value: 10 - key})).to.equal(value)
37 | })
38 | })
39 | })
40 | })
41 |
--------------------------------------------------------------------------------
/src/circos/render.js:
--------------------------------------------------------------------------------
1 | import forEach from 'lodash/forEach'
2 | import sortBy from 'lodash/sortBy'
3 | import renderLayout from './layout/render'
4 |
5 | export default function render (ids = [], removeTracks, circos) {
6 | const renderAll = ids.length === 0
7 |
8 | const svg = circos.svg
9 | .attr('width', circos.conf.width)
10 | .attr('height', circos.conf.height)
11 |
12 | if (removeTracks) {
13 | forEach(circos.tracks, (track, trackId) => {
14 | svg.select('.' + trackId).remove()
15 | })
16 | }
17 |
18 | let translated = svg.select('.all')
19 | if (translated.empty()) {
20 | translated = svg.append('g')
21 | .attr('class', 'all')
22 | .attr(
23 | 'transform',
24 | `translate(
25 | ${parseInt(circos.conf.width / 2)},
26 | ${parseInt(circos.conf.height / 2)}
27 | )`
28 | )
29 | }
30 |
31 | forEach(circos.tracks, (track, trackId) => {
32 | if (renderAll || trackId in ids) {
33 | track.render(circos, translated, trackId)
34 | }
35 | })
36 | if (renderAll || 'layout' in ids) {
37 | renderLayout(translated, circos)
38 | }
39 |
40 | // re-order tracks and layout according to z-index
41 | const trackContainers = svg.selectAll('.all > g') // .remove()
42 | const sortedTrackContainers = sortBy(
43 | trackContainers._groups[0],
44 | (elt) => elt.getAttribute('z-index')
45 | )
46 |
47 | svg.select('.all').selectAll('g')
48 | .data(sortedTrackContainers)
49 | .enter()
50 | .append((d) => d)
51 |
52 | return circos
53 | }
54 |
--------------------------------------------------------------------------------
/src/circos/tracks/Heatmap.test.js:
--------------------------------------------------------------------------------
1 | import { describe, it } from 'mocha'
2 | import { expect } from 'chai'
3 | import jsdom from 'mocha-jsdom'
4 | import { selectAll, select } from 'd3-selection'
5 | import forEach from 'lodash/forEach'
6 | import Circos from '../circos'
7 |
8 | describe('Heatmap', () => {
9 | jsdom()
10 |
11 | it('should render elements according to configuration', () => {
12 | document.body.innerHTML = ''
13 | new Circos({
14 | container: '#chart',
15 | width: 350,
16 | height: 350
17 | })
18 | .layout([{id: 'chr1', len: 249250621}, {id: 'chr2', len: 243199373}])
19 | .heatmap(
20 | 'heatmap1',
21 | [
22 | {block_id: 'chr1', start: 0, end: 1000000, value: 1},
23 | {block_id: 'chr1', start: 1000001, end: 2000000, value: 2},
24 | {block_id: 'chr2', start: 0, end: 1000000, value: 3},
25 | {block_id: 'chr2', start: 1000001, end: 2000000, value: 4}
26 | ],
27 | {
28 | color: 'Spectral',
29 | opacity: 0.8
30 | }
31 | )
32 | .render()
33 |
34 | const expectedColors = [
35 | 'rgb(158, 1, 66)',
36 | 'rgb(253, 190, 112)',
37 | 'rgb(190, 229, 160)',
38 | 'rgb(94, 79, 162)'
39 | ]
40 |
41 | const tiles = select('.heatmap1').selectAll('.tile')
42 | expect(tiles.size()).to.equal(4)
43 | forEach(tiles.nodes(), (tileNode, i) => {
44 | const tile = select(tileNode)
45 | expect(tile.attr('fill')).to.equal(expectedColors[i])
46 | expect(tile.attr('opacity')).to.equal('0.8')
47 | })
48 | })
49 | })
50 |
--------------------------------------------------------------------------------
/src/circos/tracks/Histogram.js:
--------------------------------------------------------------------------------
1 | import Track from './Track'
2 | import {parseSpanValueData} from '../data-parser'
3 | import {arc} from 'd3-shape'
4 | import assign from 'lodash/assign'
5 | import {axes, radial, values, common} from '../configs'
6 |
7 | const defaultConf = assign({
8 | direction: {
9 | value: 'out',
10 | iteratee: false
11 | },
12 | color: {
13 | value: '#fd6a62',
14 | iteratee: true
15 | },
16 | backgrounds: {
17 | value: [],
18 | iteratee: false
19 | }
20 | }, axes, radial, common, values)
21 |
22 | export default class Histogram extends Track {
23 | constructor (instance, conf, data) {
24 | super(instance, conf, defaultConf, data, parseSpanValueData)
25 | }
26 |
27 | renderDatum (parentElement, conf, layout) {
28 | parentElement.selectAll('.bin').remove()
29 | const bin = parentElement.selectAll('.bin')
30 | .data((d) => d.values)
31 |
32 | // bin.exit().remove()
33 |
34 | return bin.enter().append('path')
35 | .attr('class', 'bin')
36 | .attr('opacity', (d) => conf.opacity)
37 | .attr('d', arc()
38 | .innerRadius((d) => {
39 | if (conf.direction == 'in') {
40 | return conf.outerRadius - this.scale(d.value)
41 | }
42 | return conf.innerRadius
43 | })
44 | .outerRadius((d) => {
45 | if (conf.direction == 'out') {
46 | return conf.innerRadius + this.scale(d.value)
47 | }
48 | return conf.outerRadius
49 | })
50 | .startAngle((d) => this.theta(d.start, layout.blocks[d.block_id]))
51 | .endAngle((d) => this.theta(d.end, layout.blocks[d.block_id]))
52 | ).attr('fill', conf.colorValue)
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/circos/tracks/Line.test.js:
--------------------------------------------------------------------------------
1 | import { describe, it } from 'mocha'
2 | import { expect } from 'chai'
3 | import jsdom from 'mocha-jsdom'
4 | import { select } from 'd3-selection'
5 | import forEach from 'lodash/forEach'
6 | import Circos from '../circos'
7 |
8 | describe('Line', () => {
9 | jsdom()
10 |
11 | const buildCircos = (configuration) => {
12 | document.body.innerHTML = ''
13 | new Circos({container: '#chart', width: 350, height: 350})
14 | .layout([{id: 'january', len: 31}, {id: 'february', len: 28}])
15 | .line(
16 | 'line1',
17 | [
18 | {block_id: 'january', position: 1, value: 1},
19 | {block_id: 'january', position: 2, value: 2},
20 | {block_id: 'january', position: 3, value: 2},
21 | {block_id: 'january', position: 7, value: 2},
22 | {block_id: 'january', position: 8, value: 2},
23 | {block_id: 'february', position: 1, value: 3},
24 | {block_id: 'february', position: 2, value: 4}
25 | ],
26 | configuration
27 | )
28 | .render()
29 | }
30 |
31 | it('should render elements with given color and opacity', () => {
32 | buildCircos({
33 | color: '#d3d3d3',
34 | opacity: 0.8
35 | })
36 | const lines = select('.line1').selectAll('.line path')
37 | expect(lines.size()).to.equal(2)
38 | forEach(lines.nodes(), (lineNode, i) => {
39 | const line = select(lineNode)
40 | expect(line.attr('stroke')).to.equal('#d3d3d3')
41 | expect(line.attr('opacity')).to.equal('0.8')
42 | })
43 | })
44 |
45 | it('should split lines if data position gaps are bigger than maxGap', () => {
46 | buildCircos({
47 | maxGap: 3
48 | })
49 | const lines = select('.line1').selectAll('.line path')
50 | expect(lines.size()).to.equal(3) // january data should generate 2 lines with max gap
51 | })
52 | })
53 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pd-vis",
3 | "description": "tidb pd visualiation",
4 | "scripts": {
5 | "dev": "webpack-dev-server --inline --hot --env.dev --host 0.0.0.0",
6 | "mock": "nodemon --watch mock/server.js mock/server.js",
7 | "start": "npm-run-all -p mock dev",
8 | "build": "rimraf dist && webpack --progress --hide-modules",
9 | "preview": "static dist",
10 | "ghpages": "git add -f dist && git commit -m 'publish';git subtree push --prefix dist origin gh-pages"
11 | },
12 | "dependencies": {
13 | "axios": "^0.17.1",
14 | "clipboard-js": "^0.3.6",
15 | "d3": "^4.12.0",
16 | "d3-hierarchy": "^1.1.5",
17 | "d3-queue": "^3.0.7",
18 | "d3-scale-chromatic": "^1.1.1",
19 | "d3-svg-annotation": "^2.1.0",
20 | "d3-svg-legend": "^2.25.1",
21 | "lodash": "^4.17.4",
22 | "vue": "^2.5.2"
23 | },
24 | "engines": {
25 | "node": ">=6"
26 | },
27 | "devDependencies": {
28 | "autoprefixer": "^6.6.0",
29 | "babel-core": "^6.24.1",
30 | "babel-loader": "^6.4.1",
31 | "babel-polyfill": "^6.26.0",
32 | "babel-preset-es2015": "^6.24.1",
33 | "babel-preset-stage-0": "^6.24.1",
34 | "babel-preset-vue-app": "^1.2.0",
35 | "babel-runtime": "^6.26.0",
36 | "css-loader": "^0.27.0",
37 | "file-loader": "^0.10.1",
38 | "html-webpack-plugin": "^2.24.1",
39 | "lodash.merge": "^4.6.0",
40 | "nodemon": "^1.12.5",
41 | "npm-run-all": "^4.1.2",
42 | "postcss-loader": "^1.3.3",
43 | "rimraf": "^2.5.4",
44 | "style-loader": "^0.13.2",
45 | "uglifyjs-webpack-plugin": "^1.2.5",
46 | "url-loader": "^0.5.8",
47 | "vue-loader": "^13.3.0",
48 | "vue-template-compiler": "^2.5.2",
49 | "webpack": "^2.4.1",
50 | "webpack-dev-server": "^2.4.2"
51 | },
52 | "prettier": {
53 | "printWidth": 100,
54 | "singleQuote": true,
55 | "semi": false,
56 | "trailingComma": "es5",
57 | "jsxBracketSameLine": true
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/circos/tracks/Text.js:
--------------------------------------------------------------------------------
1 | import Track from './Track'
2 | import {parsePositionTextData} from '../data-parser'
3 | import forEach from 'lodash/forEach'
4 | import assign from 'lodash/assign'
5 | import {common, radial} from '../configs'
6 |
7 | const defaultConf = assign({
8 | style: {
9 | value: {},
10 | iteratee: true
11 | },
12 | color: {
13 | value: 'black',
14 | iteratee: true
15 | },
16 | backgrounds: {
17 | value: [],
18 | iteratee: false
19 | }
20 | }, common, radial)
21 |
22 | export default class Text extends Track {
23 | constructor (instance, conf, data) {
24 | super(instance, conf, defaultConf, data, parsePositionTextData)
25 | }
26 |
27 | renderDatum (parentElement, conf, layout) {
28 | let text = parentElement.selectAll('g')
29 | .data(d => d.values.map((item) => {
30 | item._angle = this.theta(
31 | item.position,
32 | layout.blocks[item.block_id]
33 | ) * 360 / (2 * Math.PI) - 90
34 | item._anchor = item._angle > 90 ? 'end' : 'start'
35 | item._rotate = item._angle > 90 ? 180 : 0
36 | return item
37 | }), d => JSON.stringify(d.value))
38 | text.exit().attr('class', 'slideOutDown').transition().delay(1000).remove()
39 |
40 | text = text.enter().append('g')
41 | .attr('class', 'slideInUp')
42 | .append('text')
43 | .attr('transform', (d) => {
44 | return `
45 | rotate(${d._angle})
46 | translate(${conf.innerRadius}, 0)
47 | rotate(${d._rotate})
48 | ` // rotate(${d._angle}) rotate(${d._rotate})
49 | })
50 | .attr('text-anchor', (d) => d._anchor)
51 | .selectAll('tspan')
52 | .data(d=>d.value).enter().append('tspan')
53 | .text(d=>d).attr('x', 0).attr('dy', '1.2em')
54 |
55 | text.exit().transition().delay(2000).attr('class', 'slideInUp').remove()
56 |
57 | forEach(conf.style, (value, key) => {
58 | text.style(key, value)
59 | })
60 | return text
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/circos/layout/index.js:
--------------------------------------------------------------------------------
1 | import cloneDeep from 'lodash/cloneDeep'
2 | import defaultsDeep from 'lodash/defaultsDeep'
3 | import reduce from 'lodash/reduce'
4 | import forEach from 'lodash/forEach'
5 | import defaultConf from './conf'
6 |
7 | const logger = console
8 |
9 | export default class Layout {
10 | constructor (conf, data) {
11 | if (!data) {
12 | logger.log(2, 'no layout data', '')
13 | }
14 |
15 | this.conf = defaultsDeep(conf, cloneDeep(defaultConf))
16 | this.data = data
17 | const agg = reduce(data, (aggregator, block) => {
18 | block.offset = aggregator.offset
19 | aggregator.blocks[block.id] = {
20 | label: block.label,
21 | len: block.len,
22 | color: block.color,
23 | offset: aggregator.offset
24 | }
25 | aggregator.offset += block.len
26 | return aggregator
27 | }, {blocks: {}, offset: 0})
28 | this.blocks = agg.blocks
29 | this.size = agg.offset
30 |
31 | // thanks to sum of blocks' length, compute start and end angles in radian
32 | forEach(this.data, (block, index) => {
33 | this.blocks[block.id].start =
34 | block.offset / this.size *
35 | (2 * Math.PI - this.data.length * this.conf.gap) +
36 | index * this.conf.gap
37 |
38 | this.blocks[block.id].end =
39 | (block.offset + block.len) / this.size *
40 | (2 * Math.PI - this.data.length * this.conf.gap) +
41 | index * this.conf.gap
42 |
43 | block.start =
44 | block.offset / this.size *
45 | (2 * Math.PI - this.data.length * this.conf.gap) +
46 | index * this.conf.gap
47 |
48 | block.end =
49 | (block.offset + block.len) / this.size *
50 | (2 * Math.PI - this.data.length * this.conf.gap) +
51 | index * this.conf.gap
52 | })
53 | }
54 |
55 | getAngle (blockId, unit) {
56 | const position = this.blocks[blockId].start / this.size
57 | if (unit === 'deg') { return angle * 360 }
58 |
59 | if (unit === 'rad') { return position * 2 * Math.PI }
60 |
61 | return null
62 | }
63 |
64 | summary () {
65 | return reduce(this.data, (summary, block) => {
66 | summary[block.id] = block.len
67 | return summary
68 | }, {})
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/circos/config-utils.js:
--------------------------------------------------------------------------------
1 | import cloneDeep from 'lodash/cloneDeep'
2 | import forEach from 'lodash/forEach'
3 | import isFunction from 'lodash/isFunction'
4 | import assign from 'lodash/assign'
5 | import {smartBorders} from './utils'
6 |
7 | const buildConf = (userConf = {}, defaultConf) => {
8 | let conf = {}
9 | forEach(defaultConf, (item, key) => {
10 | // if it's a leaf
11 | if (item.iteratee !== undefined) {
12 | if (!item.iteratee) {
13 | conf[key] = Object.keys(userConf).indexOf(key) > -1
14 | ? userConf[key] : item.value
15 | } else if (Object.keys(userConf).indexOf(key) > -1) {
16 | if (isFunction(userConf[key])) {
17 | conf[key] = userConf[key]
18 | } else {
19 | conf[key] = userConf[key]
20 | }
21 | } else {
22 | conf[key] = () => item.value
23 | }
24 | // else we go deeper
25 | } else {
26 | conf[key] = buildConf(userConf[key], item)
27 | }
28 | })
29 |
30 | return conf
31 | }
32 |
33 | const computeMinMax = (conf, meta) => {
34 | return {
35 | cmin: conf.min === null ? meta.min : conf.min,
36 | cmax: conf.max === null ? meta.max : conf.max
37 | }
38 | }
39 |
40 | const computeRadius = (conf, instance) => {
41 | if (conf.innerRadius === 0 && conf.outerRadius === 0) {
42 | const borders = smartBorders(conf, instance._layout, instance.tracks)
43 | return {
44 | innerRadius: borders.in,
45 | outerRadius: borders.out
46 | }
47 | }
48 | if (conf.innerRadius <= 1 && conf.outerRadius <= 1) {
49 | return {
50 | innerRadius: conf.innerRadius * instance._layout.conf.innerRadius,
51 | outerRadius: conf.outerRadius * instance._layout.conf.innerRadius
52 | }
53 | }
54 | if (conf.innerRadius <= 10 && conf.outerRadius <= 10) {
55 | return {
56 | innerRadius: conf.innerRadius * instance._layout.conf.outerRadius,
57 | outerRadius: conf.outerRadius * instance._layout.conf.outerRadius
58 | }
59 | }
60 | }
61 |
62 | const getConf = (userConf, defaultConf, meta, instance) => {
63 | let conf = buildConf(userConf, cloneDeep(defaultConf))
64 | assign(conf, computeMinMax(conf, meta), computeRadius(conf, instance))
65 | return conf
66 | }
67 |
68 | export {
69 | getConf
70 | }
71 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## TiDB Vision
2 |
3 | `tidb-vision` is a component that provides the visualization of PD scheduling through a standalone UI framework. It uses d3 (data-driven-document) as the bottom layer render library and uses the extended Circos as the basic layout engine to implement layouts such as stacks, circle sector, and chords. The transition effect between states is provided through d3 transition.
4 |
5 | ### To do
6 |
7 | - [ ] doc for `REGION_BYTE_SIZE` setting
8 | - [ ] doc for new Caddy web server docker image
9 |
10 | ### Development
11 |
12 | #### Prerequisite
13 |
14 | - Depends on the latest version of the PD server. For details, see [the related PR](https://github.com/pingcap/pd/pull/881).
15 |
16 | #### Install the dependencies
17 |
18 | - node v7+, npm
19 | - npm install
20 |
21 | > **Note:** If you need external access, modify the host configuration of devServer in `webpack.config.js`.
22 |
23 | #### Start the component
24 |
25 | - Use the default mock server: `export PD_ENDPOINT=localhost:9000;npm start`
26 | - Use the external PD server: `export PD_ENDPOINT=:;npm start`
27 |
28 | On the dashboard interface, click the entry to view the TiKV data distribution (Regions, leader), TiKV store,Region heat (I/O Read and Write rates) and PD scheduling history (Region/leader transfer) of the current cluster.
29 |
30 | 
31 |
32 | Description of the ring chart:
33 |
34 | - The peripheral text provides the store's leader/Region change within each time window, such as `+10 Regions, - 3 Leaders`.
35 | - The peripheral histogram groups: the left (the ring direction) histogram in each group shows the flow type information of store Write and hot Region Write operations; the right histogram shows the flow type information of store Read and hot Region Read operations.
36 | - The length of the ring represents the entire storage space of a specific store, and the text shows the basic information, such as the store's host IP:port and ID.
37 | - The block stacks inside the ring show three types of information in terms of "disk storage": unused space blocks (light gray), ordinary Region blocks (dark gray), and blocks as leader (green).
38 | - The chord arcs inside the ring show the Region and leader transfer information between stores.
39 |
40 | > **Note:** Depending on the current cluster status, which includes the number of TiKV instances and TiKV storage usage and so on, the component condition such as the Write and Read "heat" might be different.
--------------------------------------------------------------------------------
/src/circos/tracks/Scatter.js:
--------------------------------------------------------------------------------
1 | import Track from './Track'
2 | import {parsePositionValueData} from '../data-parser'
3 | import assign from 'lodash/assign'
4 | import {radial, axes, common, values} from '../configs'
5 | import {
6 | symbol,
7 | symbolCircle,
8 | symbolCross,
9 | symbolDiamond,
10 | symbolSquare,
11 | symbolTriangle,
12 | symbolStar,
13 | symbolWye
14 | } from 'd3-shape'
15 |
16 | const defaultConf = assign({
17 | direction: {
18 | value: 'out',
19 | iteratee: false
20 | },
21 | color: {
22 | value: '#fd6a62',
23 | iteratee: true
24 | },
25 | fill: {
26 | value: true,
27 | iteratee: false
28 | },
29 | size: {
30 | value: 15,
31 | iteratee: true
32 | },
33 | shape: {
34 | value: 'circle',
35 | iteratee: false
36 | },
37 | strokeColor: {
38 | value: '#d3d3d3',
39 | iteratee: true
40 | },
41 | strokeWidth: {
42 | value: 2,
43 | iteratee: true
44 | },
45 | backgrounds: {
46 | value: [],
47 | iteratee: false
48 | }
49 | }, axes, radial, common, values)
50 |
51 | const getSymbol = (key) => {
52 | switch (key) {
53 | case 'circle':
54 | return symbolCircle
55 | case 'cross':
56 | return symbolCross
57 | case 'diamond':
58 | return symbolDiamond
59 | case 'square':
60 | return symbolSquare
61 | case 'triangle':
62 | return symbolTriangle
63 | case 'star':
64 | return symbolStar
65 | case 'wye':
66 | return symbolWye
67 | default:
68 | return symbolCross
69 | }
70 | }
71 |
72 | export default class Scatter extends Track {
73 | constructor (instance, conf, data) {
74 | super(instance, conf, defaultConf, data, parsePositionValueData)
75 | }
76 |
77 | renderDatum (parentElement, conf, layout) {
78 | const point = parentElement.selectAll('.point')
79 | .data((d) => {
80 | d.values.forEach((item, i) => {
81 | item.symbol = symbol()
82 | .type(getSymbol(conf.shape))
83 | .size(conf.size)
84 | })
85 | return d.values
86 | })
87 | .enter().append('path')
88 | .attr('class', 'point')
89 | .attr('opacity', conf.opacity)
90 | .attr('d', (d, i, j) => d.symbol(d, i, j))
91 | .attr('transform', (d) => {
92 | return `
93 | translate(
94 | ${this.x(d, layout, conf)},
95 | ${this.y(d, layout, conf)}
96 | ) rotate(
97 | ${this.theta(
98 | d.position,
99 | layout.blocks[d.block_id]
100 | ) * 360 / (2 * Math.PI)}
101 | )`
102 | })
103 | .attr('stroke', conf.strokeColor)
104 | .attr('stroke-width', conf.strokeWidth)
105 | .attr('fill', 'none')
106 |
107 | if (conf.fill) { point.attr('fill', conf.colorValue) }
108 |
109 | return point
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/circos/axes.js:
--------------------------------------------------------------------------------
1 | import range from 'lodash/range'
2 | import reduce from 'lodash/reduce'
3 | import {arc} from 'd3-shape'
4 | import logger from './logger'
5 |
6 | const _buildAxisData = (value, axesGroup, conf) => {
7 | return {
8 | value: value,
9 | thickness: axesGroup.thickness || 1,
10 | color: axesGroup.color || '#d3d3d3',
11 | opacity: axesGroup.opacity || conf.opacity
12 | }
13 | }
14 |
15 | export const _buildAxesData = (conf) => {
16 | return reduce(conf.axes, (aggregator, axesGroup) => {
17 | if (!axesGroup.position && !axesGroup.spacing) {
18 | logger.warn('Skipping axe group with no position and spacing defined')
19 | return aggregator
20 | }
21 | if (axesGroup.position) {
22 | aggregator.push(_buildAxisData(axesGroup.position, axesGroup, conf))
23 | }
24 | if (axesGroup.spacing) {
25 | const builtAxes = range(
26 | axesGroup.start || conf.cmin,
27 | axesGroup.end || conf.cmax,
28 | axesGroup.spacing
29 | )
30 | .map((value) => {
31 | return _buildAxisData(value, axesGroup, conf)
32 | })
33 | return aggregator.concat(builtAxes)
34 | }
35 | return aggregator
36 | }, [])
37 | }
38 |
39 | export const renderAxes = (parentElement, conf, instance, scale) => {
40 | const axes = _buildAxesData(conf)
41 |
42 | const axis = arc()
43 | .innerRadius((d) => {
44 | return conf.direction === 'in'
45 | ? conf.outerRadius - scale(d.value)
46 | : conf.innerRadius + scale(d.value)
47 | })
48 | .outerRadius((d) => {
49 | return conf.direction === 'in'
50 | ? conf.outerRadius - scale(d.value)
51 | : conf.innerRadius + scale(d.value)
52 | })
53 | .startAngle(0)
54 | .endAngle((d) => d.length)
55 |
56 | const selection = parentElement
57 | .selectAll('.axis')
58 | .data((blockData) => {
59 | const block = instance._layout.blocks[blockData.key]
60 | return axes.map((d) => {
61 | return {
62 | value: d.value,
63 | thickness: d.thickness,
64 | color: d.color,
65 | opacity: d.opacity,
66 | block_id: blockData.key,
67 | length: block.end - block.start
68 | }
69 | })
70 | })
71 | .enter()
72 | .append('path')
73 | .attr('opacity', (d) => d.opacity)
74 | .attr('class', 'axis')
75 | .attr('d', axis)
76 | .attr('stroke-width', (d) => d.thickness)
77 | .attr('stroke', (d) => d.color)
78 |
79 | if (conf.showAxesTooltip) {
80 | selection.on('mouseover', (d, i) => {
81 | instance.tip
82 | .html(d.value)
83 | .transition()
84 | .style('opacity', 0.9)
85 | .style('left', (event.pageX) + 'px')
86 | .style('top', (event.pageY - 28) + 'px')
87 | })
88 | selection.on('mouseout', (d, i) => {
89 | instance.tip
90 | .transition()
91 | .duration(500)
92 | .style('opacity', 0)
93 | })
94 | }
95 |
96 | return selection
97 | }
98 |
--------------------------------------------------------------------------------
/src/circos/colors.js:
--------------------------------------------------------------------------------
1 | import isFunction from 'lodash/isFunction'
2 | import {scaleLog, scaleSequential} from 'd3-scale'
3 |
4 | import {
5 | interpolateBrBG,
6 | interpolatePRGn,
7 | interpolatePiYG,
8 | interpolatePuOr,
9 | interpolateRdBu,
10 | interpolateRdGy,
11 | interpolateRdYlBu,
12 | interpolateRdYlGn,
13 | interpolateSpectral,
14 | interpolateBlues,
15 | interpolateGreens,
16 | interpolateGreys,
17 | interpolateOranges,
18 | interpolatePurples,
19 | interpolateReds,
20 | interpolateBuGn,
21 | interpolateBuPu,
22 | interpolateGnBu,
23 | interpolateOrRd,
24 | interpolatePuBuGn,
25 | interpolatePuBu,
26 | interpolatePuRd,
27 | interpolateRdPu,
28 | interpolateYlGnBu,
29 | interpolateYlGn,
30 | interpolateYlOrBr,
31 | interpolateYlOrRd
32 | } from 'd3-scale-chromatic'
33 |
34 | const palettes = {
35 | BrBG: interpolateBrBG,
36 | PRGn: interpolatePRGn,
37 | PiYG: interpolatePiYG,
38 | PuOr: interpolatePuOr,
39 | RdBu: interpolateRdBu,
40 | RdGy: interpolateRdGy,
41 | RdYlBu: interpolateRdYlBu,
42 | RdYlGn: interpolateRdYlGn,
43 | Spectral: interpolateSpectral,
44 | Blues: interpolateBlues,
45 | Greens: interpolateGreens,
46 | Greys: interpolateGreys,
47 | Oranges: interpolateOranges,
48 | Purples: interpolatePurples,
49 | Reds: interpolateReds,
50 | BuGn: interpolateBuGn,
51 | BuPu: interpolateBuPu,
52 | GnBu: interpolateGnBu,
53 | OrRd: interpolateOrRd,
54 | PuBuGn: interpolatePuBuGn,
55 | PuBu: interpolatePuBu,
56 | PuRd: interpolatePuRd,
57 | RdPu: interpolateRdPu,
58 | YlGnBu: interpolateYlGnBu,
59 | YlGn: interpolateYlGn,
60 | YlOrBr: interpolateYlOrBr,
61 | YlOrRd: interpolateYlOrRd
62 | }
63 |
64 | export function buildColorValue (
65 | color,
66 | min = null,
67 | max = null,
68 | logScale = false,
69 | logScaleBase = Math.E
70 | ) {
71 | if (isFunction(color)) {
72 | return color
73 | }
74 | const reverse = color[0] === '-'
75 | const paletteName = color[0] === '-' ? color.slice(1) : color
76 | if (palettes[paletteName]) {
77 | const scale = buildColorScale(
78 | palettes[paletteName], min, max, reverse, logScale, logScaleBase
79 | )
80 | return (d) => {
81 | return scale(d.value)
82 | }
83 | }
84 | return color
85 | }
86 |
87 | const buildColorScale = (
88 | interpolator,
89 | min,
90 | max,
91 | reverse = false,
92 | logScale = false,
93 | logScaleBase = Math.E
94 | ) => {
95 | if (logScale && min * max <= 0) {
96 | console.warn(`As log(0) = -∞, a log scale domain must be
97 | strictly-positive or strictly-negative. logscale ignored`
98 | )
99 | }
100 |
101 | if (logScale && min * max > 0) {
102 | const scale = scaleLog()
103 | .base(logScaleBase)
104 | .domain(reverse ? [max, min] : [min, max])
105 | .range([0, 1])
106 | return scaleSequential((t) => {
107 | return interpolator(scale(t))
108 | }).domain([0, 1])
109 | }
110 | return scaleSequential(interpolator)
111 | .domain(reverse ? [max, min] : [min, max])
112 | }
113 |
--------------------------------------------------------------------------------
/src/showcase/simple-dot.js:
--------------------------------------------------------------------------------
1 | var svg = d3.select("svg"),
2 | width = +svg.attr("width"),
3 | height = +svg.attr("height");
4 |
5 | var format = d3.format(",d");
6 |
7 | var color = d3.scaleOrdinal(d3.schemeCategory20c);
8 |
9 | var pack = d3
10 | .pack()
11 | .size([width, height])
12 | .padding(14.5);
13 |
14 | var root = d3.hierarchy({
15 | name: "global",
16 | value: 12000,
17 | children: [
18 | {
19 | name: "node-1",
20 | children: Array(300).fill({
21 | value: 4,
22 | id: 1,
23 | package: "demo",
24 | class: "1"
25 | })
26 | },
27 | {
28 | name: "node-2",
29 | children: Array(700).fill({
30 | value: 4,
31 | id: 1,
32 | package: "demo",
33 | class: "1"
34 | })
35 | },
36 | {
37 | name: "node-3",
38 | children: Array(600).fill({
39 | value: 4,
40 | id: 1,
41 | package: "demo",
42 | class: "1"
43 | })
44 | }
45 | ]
46 | });
47 |
48 | var node = svg
49 | .selectAll(".node")
50 | .data(pack(root).leaves())
51 | .enter()
52 | .append("g")
53 | .attr("class", "node")
54 | .attr("transform", function(d) {
55 | return "translate(" + d.x + "," + d.y + ")";
56 | });
57 |
58 | node
59 | .append("circle")
60 | .attr("id", function(d) {
61 | return d.id;
62 | })
63 | .attr("r", function(d) {
64 | return d.r;
65 | })
66 | .style("fill", function(d) {
67 | // fake
68 | let color = d.package;
69 | if (Math.random() > 0.66) {
70 | color = "leag";
71 | }
72 | return color(color);
73 | });
74 |
75 | node
76 | .append("clipPath")
77 | .attr("id", function(d) {
78 | return "clip-" + d.id;
79 | })
80 | .append("use")
81 | .attr("xlink:href", function(d) {
82 | return "#" + d.id;
83 | });
84 |
85 | node
86 | .append("text")
87 | .attr("clip-path", function(d) {
88 | return "url(#clip-" + d.id + ")";
89 | })
90 | .selectAll("tspan")
91 | .data(function(d) {
92 | return d.class.split(/(?=[A-Z][^A-Z])/g);
93 | })
94 | .enter()
95 | .append("tspan")
96 | .attr("x", 0)
97 | .attr("y", function(d, i, nodes) {
98 | return 13 + (i - nodes.length / 2 - 0.5) * 10;
99 | })
100 | .text(function(d) {
101 | return d;
102 | });
103 |
104 | node.append("title").text(function(d) {
105 | return d.id + "\n" + format(d.value);
106 | });
107 |
108 | d3.csv(
109 | "/static/demo.csv",
110 | function(d) {
111 | d.value = +d.value;
112 | if (d.value) return d;
113 | },
114 | function(error, classes) {
115 | if (error) throw error;
116 |
117 | var root = d3
118 | .hierarchy({ children: classes })
119 | .sum(function(d) {
120 | return d.value;
121 | })
122 | .each(function(d) {
123 | if ((id = d.data.id)) {
124 | var id,
125 | i = id.lastIndexOf(".");
126 | d.id = id;
127 | d.package = id.slice(0, i);
128 | d.class = id.slice(i + 1);
129 | }
130 | });
131 | }
132 | );
133 |
--------------------------------------------------------------------------------
/src/circos/index.js:
--------------------------------------------------------------------------------
1 | import defaultsDeep from 'lodash/defaultsDeep'
2 | import forEach from 'lodash/forEach'
3 | import isArray from 'lodash/isArray'
4 | import map from 'lodash/map'
5 | import {select} from 'd3-selection'
6 | import Layout from './layout/index'
7 | import render from './render'
8 | import Text from './tracks/Text'
9 | import Highlight from './tracks/Highlight'
10 | import Histogram from './tracks/Histogram'
11 | import Chords from './tracks/Chords'
12 | import Heatmap from './tracks/Heatmap'
13 | import Line from './tracks/Line'
14 | import Scatter from './tracks/Scatter'
15 | import Stack from './tracks/Stack'
16 | import {initClipboard} from './clipboard'
17 |
18 | const defaultConf = {
19 | width: 700,
20 | height: 700,
21 | container: 'circos',
22 | defaultTrackWidth: 10
23 | }
24 |
25 | class Core {
26 | constructor (conf) {
27 | this.tracks = {}
28 | this._layout = null
29 | this.conf = defaultsDeep(conf, defaultConf)
30 | const container = select(this.conf.container).append('div')
31 | .style('position', 'relative')
32 | this.svg = container.append('svg')
33 | if (select('body').select('.circos-tooltip').empty()) {
34 | this.tip = select('body').append('div')
35 | .attr('class', 'circos-tooltip')
36 | .style('opacity', 0)
37 | } else {
38 | this.tip = select('body').select('.circos-tooltip')
39 | }
40 |
41 | this.clipboard = initClipboard(this.conf.container)
42 | }
43 |
44 | removeTracks (trackIds) {
45 | if (typeof (trackIds) === 'undefined') {
46 | map(this.tracks, (track, id) => {
47 | this.svg.select('.' + id).remove()
48 | })
49 | this.tracks = {}
50 | } else if (typeof (trackIds) === 'string') {
51 | this.svg.select('.' + trackIds).remove()
52 | delete this.tracks[trackIds]
53 | } else if (isArray(trackIds)) {
54 | forEach(trackIds, function (trackId) {
55 | this.svg.select('.' + trackId).remove()
56 | delete this.tracks[trackId]
57 | })
58 | } else {
59 | console.warn('removeTracks received an unhandled attribute type')
60 | }
61 |
62 | return this
63 | }
64 |
65 | layout (data, conf) {
66 | this._layout = new Layout(conf, data)
67 | return this
68 | }
69 |
70 | chords (id, data, conf) {
71 | this.tracks[id] = new Chords(this, conf, data)
72 | return this
73 | }
74 | heatmap (id, data, conf) {
75 | this.tracks[id] = new Heatmap(this, conf, data)
76 | return this
77 | }
78 | highlight (id, data, conf) {
79 | this.tracks[id] = new Highlight(this, conf, data)
80 | return this
81 | }
82 | histogram (id, data, conf) {
83 | this.tracks[id] = new Histogram(this, conf, data)
84 | return this
85 | }
86 | line (id, data, conf) {
87 | this.tracks[id] = new Line(this, conf, data)
88 | return this
89 | }
90 | scatter (id, data, conf) {
91 | this.tracks[id] = new Scatter(this, conf, data)
92 | return this
93 | }
94 | stack (id, data, conf) {
95 | this.tracks[id] = new Stack(this, conf, data)
96 | return this
97 | }
98 | text (id, data, conf) {
99 | this.tracks[id] = new Text(this, conf, data)
100 | return this
101 | }
102 | render (ids, removeTracks) {
103 | render(ids, removeTracks, this)
104 | }
105 | }
106 |
107 | const Circos = (conf) => {
108 | const instance = new Core(conf)
109 | return instance
110 | }
111 |
112 | module.exports = Circos
113 |
--------------------------------------------------------------------------------
/src/circos/tracks/Line.js:
--------------------------------------------------------------------------------
1 | import Track from './Track'
2 | import {parsePositionValueData} from '../data-parser'
3 | import assign from 'lodash/assign'
4 | import reduce from 'lodash/reduce'
5 | import sortBy from 'lodash/sortBy'
6 | import {axes, radial, common, values} from '../configs'
7 | import {curveLinear, radialLine, radialArea} from 'd3-shape'
8 |
9 | const defaultConf = assign({
10 | direction: {
11 | value: 'out',
12 | iteratee: false
13 | },
14 | color: {
15 | value: '#fd6a62',
16 | iteratee: true
17 | },
18 | fill: {
19 | value: false,
20 | iteratee: false
21 | },
22 | fillColor: {
23 | value: '#d3d3d3',
24 | iteratee: true
25 | },
26 | thickness: {
27 | value: 1,
28 | iteratee: true
29 | },
30 | maxGap: {
31 | value: null,
32 | iteratee: false
33 | },
34 | backgrounds: {
35 | value: [],
36 | iteratee: false
37 | }
38 | }, axes, radial, common, values)
39 |
40 | const splitByGap = (points, maxGap) => {
41 | return reduce(sortBy(points, 'position'), (aggregator, datum) => {
42 | if (aggregator.position === null) { return {position: datum.position, groups: [[datum]]} }
43 | if (datum.position > aggregator.position + maxGap) {
44 | aggregator.groups.push([datum])
45 | } else {
46 | aggregator.groups[aggregator.groups.length - 1].push(datum)
47 | }
48 | aggregator.position = datum.position
49 | return aggregator
50 | }, {position: null, groups: []}).groups
51 | }
52 |
53 | export default class Line extends Track {
54 | constructor (instance, conf, data) {
55 | super(instance, conf, defaultConf, data, parsePositionValueData)
56 | }
57 |
58 | renderDatum (parentElement, conf, layout) {
59 | const line = radialLine()
60 | .angle((d) => d.angle)
61 | .radius((d) => d.radius)
62 | .curve(curveLinear)
63 |
64 | const area = radialArea()
65 | .angle((d) => d.angle)
66 | .innerRadius((d) => d.innerRadius)
67 | .outerRadius((d) => d.outerRadius)
68 | .curve(curveLinear)
69 |
70 | const generator = conf.fill ? area : line
71 |
72 | const buildRadius = (height) => {
73 | if (conf.fill) {
74 | return {
75 | innerRadius: conf.direction === 'out'
76 | ? conf.innerRadius : conf.outerRadius - height,
77 | outerRadius: conf.direction === 'out'
78 | ? conf.innerRadius + height : conf.outerRadius
79 | }
80 | } else {
81 | return {
82 | radius: conf.direction === 'out'
83 | ? conf.innerRadius + height : conf.outerRadius - height
84 | }
85 | }
86 | }
87 |
88 | const selection = parentElement
89 | .selectAll('.line')
90 | .data((d) => conf.maxGap ? splitByGap(d.values, conf.maxGap) : [d.values])
91 | .enter()
92 | .append('g')
93 | .attr('class', 'line')
94 | .append('path')
95 | .datum((d) => {
96 | return d.map((datum) => {
97 | const height = this.scale(datum.value)
98 | return assign(datum, {
99 | angle: this.theta(datum.position, layout.blocks[datum.block_id])
100 | }, buildRadius(height))
101 | })
102 | })
103 | .attr('d', generator)
104 | .attr('opacity', conf.opacity)
105 | .attr('stroke-width', conf.thickness)
106 | .attr('stroke', conf.colorValue)
107 | .attr('fill', 'none')
108 |
109 | if (conf.fill) { selection.attr('fill', conf.fillColor) }
110 |
111 | return selection
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const resolve = require("path").resolve;
2 | const webpack = require("webpack");
3 | const HtmlWebpackPlugin = require("html-webpack-plugin");
4 | const url = require("url");
5 | const publicPath = "";
6 | const express = require("express");
7 | const path = require("path");
8 | const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
9 |
10 | let proxy = {}
11 | if(process.env.PD_ENDPOINT) {
12 | console.log('PD_ENDPOINT: Using ', process.env.PD_ENDPOINT)
13 | proxy = {
14 | "/pd/api/v1": {
15 | target: 'http://'+process.env.PD_ENDPOINT,
16 | changeOrigin: true
17 | }
18 | }
19 | } else {
20 | console.log('PD_ENDPOINT: Using default Mock Server')
21 | proxy = {
22 | "/pd/api/v1": {
23 | target: `http://localhost:${process.env.MOCK_PORT || 9000}`,
24 | }
25 | }
26 | }
27 |
28 | module.exports = (options = {}) => ({
29 | entry: {
30 | vendor: "./src/vendor",
31 | index: ['babel-polyfill', "./src/main.js"]
32 | },
33 | output: {
34 | path: resolve(__dirname, "dist"),
35 | filename: options.dev ? "[name].js" : "[name].js?[chunkhash]",
36 | chunkFilename: "[id].js?[chunkhash]",
37 | publicPath: options.dev ? "/assets/" : publicPath
38 | },
39 | module: {
40 | rules: [
41 | {
42 | test: /\.vue$/,
43 | use: ["vue-loader"]
44 | },
45 | {
46 | test: /\.js$/,
47 | use: [{
48 | loader: "babel-loader",
49 | query: {
50 | presets: [
51 | 'es2015',
52 | 'stage-0'
53 | ]
54 | }
55 | }],
56 | exclude: /node_modules/
57 | },
58 | {
59 | test: /\.css$/,
60 | use: ["style-loader", "css-loader", "postcss-loader"]
61 | },
62 | {
63 | test: /\.(png|jpg|jpeg|gif|eot|ttf|woff|woff2|svg|svgz)(\?.+)?$/,
64 | use: [
65 | {
66 | loader: "url-loader",
67 | options: {
68 | limit: 10000
69 | }
70 | }
71 | ]
72 | }
73 | ]
74 | },
75 | plugins: [
76 | new UglifyJSPlugin({
77 | uglifyOptions: {
78 | beautify: false,
79 | ecma: 6,
80 | sourceMap: false,
81 | mangle: false,
82 | compress: false,
83 | comments: false
84 | }
85 | }),
86 | // new webpack.optimize.CommonsChunkPlugin({
87 | // names: ["vendor", "manifest"]
88 | // }),
89 | new webpack.EnvironmentPlugin({
90 | 'NODE_ENV': 'dev',
91 | 'REGION_BYTE_SIZE': '100663296' // default size
92 | }),
93 | new webpack.DefinePlugin({
94 | 'API_URL': "'http://hardsets.dev'"
95 | }),
96 | new HtmlWebpackPlugin({
97 | template: "src/index.html"
98 | }),
99 | ],
100 | resolve: {
101 | alias: {
102 | "~": resolve(__dirname, "src")
103 | }
104 | },
105 | devServer: {
106 | host: "127.0.0.1",
107 | port: process.env.PORT || 8010,
108 | setup(app) {
109 | app.use(
110 | "/static/",
111 | express.static(path.join(__dirname, "src", "assets"))
112 | );
113 | if(process.env.NODE_ENV="production") {
114 | app.use(
115 | "/",
116 | express.static(path.join(__dirname, "dist"))
117 | );
118 | }
119 | },
120 | headers: {
121 | "Access-Control-Allow-Origin": "*",
122 | "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
123 | "Access-Control-Allow-Headers": "X-Requested-With, content-type, Authorization"
124 | },
125 | proxy,
126 | historyApiFallback: {
127 | index: url.parse(options.dev ? "/assets/" : publicPath).pathname
128 | }
129 | },
130 | // devtool: options.dev ? "#eval-source-map" : "#source-map"
131 | });
132 |
--------------------------------------------------------------------------------
/src/circos/data-parser.test.js:
--------------------------------------------------------------------------------
1 | import { parseSpanValueData } from './data-parser'
2 | import { forEach } from 'lodash'
3 | import { expect } from 'chai'
4 |
5 | describe('dataParser', () => {
6 | describe('parseSpanValueData', () => {
7 | const cases = [
8 | {
9 | layout: {january: 31, february: 28, march: 31},
10 | data: [
11 | {
12 | block_id: 'january',
13 | start: 1,
14 | end: 2,
15 | value: 3
16 | },
17 | {
18 | block_id: 'january',
19 | start: 1,
20 | end: 31,
21 | value: 10
22 | },
23 | {
24 | block_id: 'february',
25 | start: 1,
26 | end: 28,
27 | value: 4
28 | },
29 | {
30 | block_id: 'march',
31 | start: 1,
32 | end: 2,
33 | value: 5
34 | },
35 | {
36 | block_id: 'march',
37 | start: 1,
38 | end: 2,
39 | value: 7
40 | }
41 | ],
42 | expected: {
43 | data: [
44 | {
45 | key: 'january',
46 | values: [
47 | {block_id: 'january', start: 1, end: 2, value: 3},
48 | {block_id: 'january', start: 1, end: 31, value: 10}
49 | ]
50 | },
51 | {
52 | key: 'february',
53 | values: [
54 | {block_id: 'february', start: 1, end: 28, value: 4}
55 | ]
56 | },
57 | {
58 | key: 'march',
59 | values: [
60 | {block_id: 'march', start: 1, end: 2, value: 5},
61 | {block_id: 'march', start: 1, end: 2, value: 7}
62 | ]
63 | }
64 | ],
65 | meta: {
66 | min: 3,
67 | max: 10
68 | }
69 | }
70 | },
71 | {
72 | layout: {january: 31, february: 28, march: 31},
73 | data: [],
74 | expected: {
75 | data: [],
76 | meta: {
77 | min: null,
78 | max: null
79 | }
80 | }
81 | }
82 | ]
83 |
84 | forEach(cases, (dataset) => {
85 | it('should return expected results', () => {
86 | const result = parseSpanValueData(dataset.data, dataset.layout)
87 | expect(result).to.deep.equal(dataset.expected)
88 | })
89 | })
90 | })
91 | })
92 | //
93 | // # it 'should not log an error when everything is ok', ->
94 | // # log.reset()
95 | // # result = circosJS.parseSpanValueData data, layoutSummary
96 | // # expect(log).to.not.have.been.called
97 | // #
98 | // # it 'should log an error and remove datum when a position is not a number', ->
99 | // # log.reset()
100 | // # errorData = [
101 | // # ['january', 'a', 2,3],
102 | // # ]
103 | // # result = circosJS.parseSpanValueData errorData, layoutSummary
104 | // # expect(log).to.have.been.calledOnce
105 | // # expect(result.data).to.be.empty
106 | // #
107 | // # it 'should log an error and remove datum when a layout id is unknown', ->
108 | // # log.reset()
109 | // # errorData = [
110 | // # ['42', 'a', 2,3],
111 | // # ]
112 | // # result = circosJS.parseSpanValueData errorData, layoutSummary
113 | // # expect(log).to.have.been.calledOnce
114 | // # expect(result.data).to.be.empty
115 | // #
116 | // # it 'should log an error and remove datum when a value is not a number', ->
117 | // # log.reset()
118 | // # errorData = [
119 | // # ['january', 1, 2,'a'],
120 | // # ]
121 | // # result = circosJS.parseSpanValueData errorData, layoutSummary
122 | // # expect(log).to.have.been.calledOnce
123 | // # expect(result.data).to.be.empty
124 |
--------------------------------------------------------------------------------
/src/circos/utils.test.js:
--------------------------------------------------------------------------------
1 | import {smartBorders, computeMinMax, buildScale} from './utils'
2 | import {forEach} from 'lodash'
3 | import {expect} from 'chai'
4 |
5 | describe('utils', () => {
6 | describe('smartBorders', () => {
7 | const cases = [
8 | {
9 | trackEdges: [[10, 20], [31, 40]],
10 | layout: [41, 50],
11 | trackWidth: 10,
12 | expected: {in: 50, out: 60}
13 | },
14 | {
15 | trackEdges: [[undefined, undefined], [31, 40]],
16 | layout: [20, 25],
17 | trackWidth: 15,
18 | expected: {in: 40, out: 55}
19 | }
20 | ]
21 |
22 | forEach(cases, (dataset) => {
23 | it('should return the expected borders', () => {
24 | const conf = {defaultTrackWidth: dataset.trackWidth}
25 | const layout = {
26 | conf: {
27 | innerRadius: dataset.layout[0],
28 | outerRadius: dataset.layout[1]
29 | }
30 | }
31 | const tracks = dataset.trackEdges.map((track) => {
32 | return ({
33 | conf: {
34 | innerRadius: track[0],
35 | outerRadius: track[1]
36 | }
37 | })
38 | })
39 | expect(smartBorders(conf, layout, tracks))
40 | .to.deep.equal(dataset.expected)
41 | })
42 | })
43 | })
44 |
45 | describe('computeMinMax', () => {
46 | forEach([
47 | {
48 | conf: {min: 3, max: 10},
49 | meta: {min: 1, max: 13},
50 | expected: {cmin: 3, cmax: 10}
51 | },
52 | {
53 | conf: {min: null, max: null},
54 | meta: {min: 1, max: 13},
55 | expected: {cmin: 1, cmax: 13}
56 | }
57 | ], (dataset) => {
58 | it('should handle the null value in conf', () => {
59 | const conf = computeMinMax(dataset.conf, dataset.meta)
60 | forEach(dataset.expected, (value, key) => {
61 | expect(conf[key]).to.equal(value)
62 | })
63 | })
64 | })
65 | })
66 |
67 | describe('buildScale', () => {
68 | forEach([
69 | {
70 | value: 0,
71 | domain: [0, 0],
72 | height: 0,
73 | logScale: false,
74 | expected: 0
75 | },
76 | {
77 | value: 5,
78 | domain: [0, 0],
79 | height: 0,
80 | logScale: false,
81 | expected: 0
82 | },
83 | {
84 | value: 5,
85 | domain: [0, 10],
86 | height: 0,
87 | logScale: false,
88 | expected: 0
89 | },
90 | {
91 | value: 5,
92 | domain: [0, 10],
93 | height: 10,
94 | logScale: false,
95 | expected: 5
96 | },
97 | {
98 | value: 5,
99 | domain: [0, 10],
100 | height: 2,
101 | logScale: false,
102 | expected: 1
103 | },
104 | {
105 | value: 2,
106 | domain: [0, 8],
107 | height: 4,
108 | logScale: false,
109 | expected: 1
110 | },
111 | {
112 | value: 10,
113 | domain: [0, 8],
114 | height: 4,
115 | logScale: false,
116 | expected: 4
117 | },
118 | {
119 | value: 2,
120 | domain: [0, 8],
121 | height: 4,
122 | logScale: true,
123 | expected: 1,
124 | warn: true
125 | },
126 | {
127 | value: Math.exp(6),
128 | domain: [Math.exp(0), Math.exp(8)],
129 | height: 4,
130 | logScale: true,
131 | expected: 3
132 | },
133 | {
134 | value: 1000,
135 | domain: [1, 10000],
136 | height: 4,
137 | logScale: true,
138 | logScaleBase: 10,
139 | expected: 3
140 | }
141 | ], (d) => {
142 | it('should build a well designed scale', () => {
143 | const result = buildScale(
144 | d.domain[0],
145 | d.domain[1],
146 | d.height,
147 | d.logScale,
148 | d.logScaleBase
149 | )(d.value)
150 | expect(Math.round(result * 100)).to.equal(d.expected * 100)
151 | })
152 | })
153 | })
154 | })
155 |
--------------------------------------------------------------------------------
/src/circos/tracks/Chords.js:
--------------------------------------------------------------------------------
1 | import Track from './Track'
2 | import {parseChordData} from '../data-parser'
3 | import {registerTooltip} from '../behaviors/tooltip'
4 | import {ribbon} from 'd3-chord'
5 | import assign from 'lodash/assign'
6 | import isFunction from 'lodash/isFunction'
7 | import {event} from 'd3-selection'
8 |
9 | const d3 = require('d3')
10 |
11 | import {common, values} from '../configs'
12 |
13 | const defaultConf = assign({
14 | color: {
15 | value: '#fd6a62',
16 | iteratee: true
17 | },
18 | radius: {
19 | value: null,
20 | iteratee: false
21 | }
22 | }, common, values)
23 |
24 | const normalizeRadius = (radius, layoutRadius) => {
25 | if (radius >= 1) return radius
26 | return radius * layoutRadius
27 | }
28 |
29 | export default class Chords extends Track {
30 | constructor (instance, conf, data) {
31 | super(instance, conf, defaultConf, data, parseChordData)
32 | }
33 |
34 | getCoordinates (d, layout, conf, datum) {
35 | const block = layout.blocks[d.id]
36 | const startAngle = block.start + d.start /
37 | block.len * (block.end - block.start)
38 | const endAngle = block.start + d.end /
39 | block.len * (block.end - block.start)
40 |
41 | let radius
42 | if (isFunction(conf.radius)) {
43 | radius = normalizeRadius(conf.radius(datum), layout.conf.innerRadius)
44 | } else if (conf.radius) {
45 | radius = normalizeRadius(conf.radius, layout.conf.innerRadius)
46 | }
47 |
48 | if (!radius) {
49 | radius = layout.conf.innerRadius
50 | }
51 |
52 | return {
53 | radius,
54 | startAngle,
55 | endAngle
56 | }
57 | }
58 |
59 | renderChords (parentElement, name, conf, data, instance, getCoordinates) {
60 | let track = parentElement.select('g')
61 | if(track.empty()) {
62 | track = parentElement.append('g')
63 | }
64 |
65 |
66 | let _link = track
67 | .selectAll('.chord')
68 | .data(data, function(d) {
69 | return `${d.type}-${d.source.start}-${d.target.start}`
70 | })
71 |
72 | let link = _link.enter().append('path')
73 | .attr('class', function(d){
74 | return 'chord ' + d.type
75 | }).attr('d', ribbon()
76 | .source((d) => getCoordinates(d.source, instance._layout, this.conf, d))
77 | .target((d) => getCoordinates(d.target, instance._layout, this.conf, d))
78 | ).attr('opacity', 1)
79 | .attr('stroke-opacity', 0.7)
80 | .attr('fill', 'white').attr('stroke', 'red')
81 |
82 | _link.exit().transition()
83 | .duration(2000)
84 | .attr('opacity', 0)
85 | .attr('stroke', 'blue')
86 | .attr('fill', 'yellow').remove()
87 |
88 | link.transition().duration(2000)
89 | .style("fill", conf.colorValue)
90 | .attr('opacity', conf.opacity)
91 |
92 |
93 |
94 |
95 |
96 |
97 | // link
98 | // .transition().duration(1000)
99 | // .attr('opacity', conf.opacity)
100 | // .on('mouseover', (d) => {
101 | // // this.dispatch.call('mouseover', this, d)
102 | // // instance.clipboard.attr('value', conf.tooltipContent(d))
103 | // })
104 | // .on('mouseout', (d) => {
105 | // // this.dispatch.call('mouseout', this, d)
106 | // })
107 |
108 | Object.keys(conf.events).forEach((eventName) => {
109 | link.on(eventName, function (d, i, nodes) { conf.events[eventName](d, i, nodes, event) })
110 | })
111 |
112 | return link
113 | }
114 |
115 | render (instance, parentElement, name) {
116 | // parentElement.select('.' + name).remove()
117 | let track = parentElement.select('g.'+name)
118 | if(track.empty()) {
119 | track = parentElement.append('g')
120 | .attr('class', name)
121 | .attr('z-index', this.conf.zIndex)
122 | }
123 |
124 | const selection = this.renderChords(
125 | track,
126 | name,
127 | this.conf,
128 | this.data,
129 | instance,
130 | this.getCoordinates
131 | )
132 | if (this.conf.tooltipContent) {
133 | registerTooltip(this, instance, selection, this.conf)
134 | }
135 | return this
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/src/circos/layout/render.js:
--------------------------------------------------------------------------------
1 | import {arc} from 'd3-shape'
2 | import {range} from 'd3-array'
3 | import {event} from 'd3-selection'
4 |
5 | function renderLayoutLabels (conf, block) {
6 | const radius = conf.innerRadius + conf.labels.radialOffset
7 |
8 | const labelArc = arc()
9 | .innerRadius(radius)
10 | .outerRadius(radius)
11 | .startAngle((d, i) => d.start)
12 | .endAngle((d, i) => d.end)
13 |
14 | block.append('path')
15 | .attr('fill', 'none')
16 | .attr('stroke', 'none')
17 | .attr('d', labelArc)
18 | .attr('id', d => 'arc-label' + d.id)
19 |
20 | const label = block.append('text')
21 | .style('font-size', '' + conf.labels.size + 'px')
22 | .attr('text-anchor', 'middle')
23 |
24 | // http://stackoverflow.com/questions/20447106/how-to-center-horizontal-and-vertical-text-along-an-textpath-inside-an-arc-usi
25 | label.append('textPath')
26 | .attr('startOffset', '25%')
27 | .attr('xlink:href', (d) => '#arc-label' + d.id)
28 | .style('fill', conf.labels.color)
29 | .text((d) => d.label)
30 | }
31 |
32 | function renderLayoutTicks (conf, layout, instance) {
33 | // Returns an array of tick angles and labels, given a block.
34 | function blockTicks (d) {
35 | const k = (d.end - d.start) / d.len
36 | return range(0, d.len, conf.ticks.spacing).map((v, i) => {
37 | return {
38 | angle: v * k + d.start,
39 | label: displayLabel(v, i)
40 | }
41 | })
42 | }
43 |
44 | function displayLabel (v, i) {
45 | if (conf.ticks.labels === false) {
46 | return null
47 | } else if (conf.ticks.labelDisplay0 === false && i === 0) {
48 | return null
49 | } else if (i % conf.ticks.labelSpacing) {
50 | return null
51 | } else {
52 | return v / conf.ticks.labelDenominator + conf.ticks.labelSuffix
53 | }
54 | }
55 |
56 | const ticks = layout.append('g').selectAll('g')
57 | .data(instance._layout.data)
58 | .enter().append('g').selectAll('g')
59 | .data(blockTicks)
60 | .enter().append('g')
61 | .attr(
62 | 'transform',
63 | (d) => 'rotate(' + (d.angle * 180 / Math.PI - 90) + ')' + 'translate(' + conf.outerRadius + ',0)'
64 | )
65 |
66 | ticks.append('line')
67 | .attr('x1', 0)
68 | .attr('y1', 1)
69 | .attr('x2', (d, i) => {
70 | if (i % conf.ticks.majorSpacing) {
71 | return conf.ticks.size.minor
72 | } else {
73 | return conf.ticks.size.major
74 | }
75 | })
76 | .attr('y2', 1)
77 | .style('stroke', conf.ticks.color)
78 |
79 | ticks.append('text')
80 | .attr('x', 8)
81 | .attr('dy', '.35em')
82 | .attr(
83 | 'transform',
84 | (d) => d.angle > Math.PI ? 'rotate(180)translate(-16)' : null
85 | )
86 | .style('text-anchor', (d) => d.angle > Math.PI ? 'end' : null)
87 | .style('font-size', '' + conf.ticks.labelSize + 'px')
88 | .style('fill', conf.ticks.labelColor)
89 | .text((d) => d.label)
90 | }
91 |
92 | export default function renderLayout (parentElement, instance) {
93 | const conf = instance._layout.conf
94 | parentElement.select('.cs-layout').remove()
95 |
96 | const layout = parentElement
97 | .append('g')
98 | .attr('class', 'cs-layout')
99 | .attr('z-index', conf.zIndex)
100 | .on('click', conf.onClick)
101 |
102 | const block = layout
103 | .selectAll('g')
104 | .data(instance._layout.data)
105 | .enter()
106 | .append('g')
107 | .attr('class', (d) => d.id)
108 | .attr('opacity', conf.opacity)
109 |
110 | Object.keys(conf.events).forEach((eventName) => {
111 | block.on(eventName, function (d, i, nodes) { conf.events[eventName](d, i, nodes, event) })
112 | })
113 |
114 | const entry = arc()
115 | .innerRadius(conf.innerRadius)
116 | .outerRadius(conf.outerRadius)
117 | .cornerRadius(conf.cornerRadius)
118 | .startAngle((d) => d.start)
119 | .endAngle((d) => d.end)
120 |
121 | block.append('path')
122 | .attr('d', entry)
123 | .attr('fill', (d) => d.color)
124 | .attr('id', (d) => d.id)
125 |
126 | if (conf.labels.display) {
127 | renderLayoutLabels(conf, block)
128 | }
129 |
130 | if (conf.ticks.display) {
131 | renderLayoutTicks(conf, layout, instance)
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/circos/tracks/Stack.js:
--------------------------------------------------------------------------------
1 | import Track from './Track'
2 | import {parseSpanValueData} from '../data-parser'
3 | import {arc} from 'd3-shape'
4 | import assign from 'lodash/assign'
5 | import forEach from 'lodash/forEach'
6 | import {axes, radial, values, common} from '../configs'
7 |
8 | const defaultConf = assign({
9 | color: {
10 | value: '#fd6a62',
11 | iteratee: true
12 | },
13 | direction: {
14 | value: 'out',
15 | iteratee: false
16 | },
17 | thickness: {
18 | value: 10,
19 | iteratee: false
20 | },
21 | radialMargin: {
22 | value: 2,
23 | iteratee: false
24 | },
25 | margin: {
26 | value: 2,
27 | iteratee: false
28 | },
29 | strokeWidth: {
30 | value: 1,
31 | iteratee: true
32 | },
33 | strokeColor: {
34 | value: '#000000',
35 | iteratee: true
36 | },
37 | backgrounds: {
38 | value: [],
39 | iteratee: false
40 | }
41 | }, axes, radial, values, common)
42 |
43 | export default class Stack extends Track {
44 | constructor (instance, conf, data) {
45 | super(instance, conf, defaultConf, data, parseSpanValueData)
46 | this.buildLayers(this.data, this.conf.margin)
47 | }
48 |
49 | buildLayers (data, margin) {
50 | forEach(data, (block, idx) => {
51 | block.values = block.values.sort((a, b) => {
52 | if (a.start < b.start) {
53 | return -1
54 | }
55 | if (a.start == b.start && a.end > b.end) {
56 | return -1
57 | }
58 | if (a.start == b.start && a.end == b.end) {
59 | return 0
60 | }
61 | return 1
62 | })
63 | let layers = []
64 | forEach(block.values, (datum) => {
65 | let placed = false
66 | forEach(layers, (layer, i) => {
67 | // try to place datum
68 | const lastDatumInLayer = layer.slice(0).pop()
69 | if (lastDatumInLayer.end + margin < datum.start) {
70 | layer.push(datum)
71 | datum.layer = i
72 | placed = true
73 | return false
74 | }
75 | })
76 | if (!placed) {
77 | datum.layer = layers.length
78 | layers.push([datum])
79 | }
80 | })
81 | })
82 | }
83 |
84 | datumRadialPosition (d) {
85 | const radialStart = (this.conf.thickness + this.conf.radialMargin) *
86 | d.layer
87 | const radialEnd = radialStart + this.conf.thickness
88 | if (this.conf.direction === 'out') {
89 | return [
90 | Math.min(this.conf.innerRadius + radialStart, this.conf.outerRadius),
91 | Math.min(this.conf.innerRadius + radialEnd, this.conf.outerRadius)
92 | ]
93 | }
94 |
95 | if (this.conf.direction === 'in') {
96 | return [
97 | Math.max(this.conf.outerRadius - radialEnd, this.conf.innerRadius),
98 | this.conf.outerRadius - radialStart
99 | ]
100 | }
101 |
102 | if (this.conf.direction === 'center') {
103 | const origin = Math.floor(
104 | (this.conf.outerRadius + this.conf.innerRadius) / 2
105 | )
106 | const radialStart = (this.conf.thickness + this.conf.radialMargin) *
107 | Math.floor(d.layer / 2)
108 | const radialEnd = radialStart + this.conf.thickness
109 |
110 | if (d.layer % 2 === 0) {
111 | return [
112 | origin + radialStart,
113 | origin + radialEnd
114 | ]
115 | } else {
116 | return [
117 | origin - radialStart - this.conf.radialMargin,
118 | origin - radialEnd - this.conf.radialMargin
119 | ]
120 | }
121 | }
122 | }
123 |
124 | renderDatum (parentElement, conf, layout) {
125 | const that = this
126 |
127 | let tiles = parentElement.selectAll('.tile')
128 | .data((d) => {
129 | return d.values.map((datum) => {
130 | const radius = that.datumRadialPosition(datum)
131 | return assign(datum, {
132 | innerRadius: radius[0],
133 | outerRadius: radius[1],
134 | startAngle: this.theta(datum.start, layout.blocks[datum.block_id]),
135 | endAngle: this.theta(datum.end, layout.blocks[datum.block_id])
136 | })
137 | })
138 | }, (d)=>d.value)
139 |
140 | tiles.exit().transition()
141 | .duration(2000)
142 | .attr('opacity', 0)
143 | .attr('stroke', 'blue')
144 | .attr('fill', 'yellow').remove()
145 |
146 | let x = tiles.enter().append('path')
147 | .attr('class', function(d) {
148 | return 'tile' + (d.type ? (' ' + d.type) : '')
149 | })
150 |
151 | x.attr('d', arc())
152 | .attr('opacity', conf.opacity)
153 | .attr('stroke-width', conf.strokeWidth)
154 | .attr('stroke', conf.strokeColor)
155 | .attr('fill', conf.colorValue)
156 | return x
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/src/circos/data-parser.js:
--------------------------------------------------------------------------------
1 | import keys from 'lodash/keys'
2 | import includes from 'lodash/includes'
3 | import every from 'lodash/every'
4 | import map from 'lodash/map'
5 | import {nest} from 'd3-collection'
6 | import {min, max} from 'd3-array'
7 |
8 | const logger = console
9 |
10 | function checkParent (key, index, layoutSummary, header) {
11 | if (!includes(keys(layoutSummary), key)) {
12 | logger.log(
13 | 1,
14 | 'datum',
15 | 'unknown parent id',
16 | {line: index + 1, value: key, header: header, layoutSummary: layoutSummary}
17 | )
18 | return false
19 | }
20 | return true
21 | }
22 |
23 | function checkNumber (keys, index) {
24 | return every(keys, (value, header) => {
25 | if (isNaN(value)) {
26 | logger.log(
27 | 1,
28 | 'datum',
29 | 'not a number',
30 | {line: index + 1, value: value, header: header}
31 | )
32 | return false
33 | }
34 | return true
35 | })
36 | }
37 |
38 | function normalize (data, idKeys) {
39 | const sampleKeys = keys(data[0])
40 | const isObject = every(map(idKeys, (key) => includes(sampleKeys, key)))
41 | if (isObject) {
42 | return map(data, (datum) => {
43 | return map(idKeys, (key) => datum[key])
44 | })
45 | }
46 |
47 | return data
48 | }
49 |
50 | function buildOutput (data) {
51 | return {
52 | data: nest().key((datum) => datum.block_id).entries(data),
53 | meta: {
54 | min: min(data, (d) => d.value),
55 | max: max(data, (d) => d.value)
56 | }
57 | }
58 | }
59 |
60 | export function parseSpanValueData (data, layoutSummary) {
61 | // ['parent_id', 'start', 'end', 'value']
62 | if (data.length === 0) {
63 | return {data: [], meta: {min: null, max: null}}
64 | }
65 |
66 | const filteredData = data
67 | .filter((datum, index) =>
68 | checkParent(datum.block_id, index, layoutSummary, 'parent')
69 | )
70 |
71 | return buildOutput(filteredData)
72 | }
73 |
74 | export function parseSpanStringData (data, layoutSummary) {
75 | // ['parent_id', 'start', 'end', 'value']
76 |
77 | if (data.length === 0) {
78 | return {data: [], meta: {min: null, max: null}}
79 | }
80 |
81 | const filteredData = data
82 | .filter((datum, index) =>
83 | checkParent(datum.block_id, index, layoutSummary, 'parent')
84 | )
85 | .filter((datum, index) =>
86 | checkNumber({start: datum.start, end: datum.end}, index)
87 | )
88 | .filter((datum) => {
89 | if (datum.start < 0 || datum.end > layoutSummary[datum.block_id]) {
90 | logger.log(
91 | 2,
92 | 'position',
93 | 'position inconsistency',
94 | {datum: datum, layoutSummary: layoutSummary}
95 | )
96 | return false
97 | }
98 | return true
99 | })
100 |
101 | return buildOutput(filteredData)
102 | }
103 |
104 | export function parsePositionValueData (data, layoutSummary) {
105 | // ['parent_id', 'position', 'value']
106 | if (data.length === 0) {
107 | return {data: [], meta: {min: null, max: null}}
108 | }
109 |
110 | const filteredData = data
111 | .filter((datum, index) =>
112 | checkParent(datum.block_id, index, layoutSummary, 'parent')
113 | )
114 | .filter((datum, index) =>
115 | checkNumber({position: datum.position, value: datum.value}, index)
116 | )
117 |
118 | return buildOutput(filteredData)
119 | }
120 |
121 | export function parsePositionTextData (data, layoutSummary) {
122 | // ['parent_id', 'position', 'value']
123 | if (data.length === 0) {
124 | return {data: [], meta: {min: null, max: null}}
125 | }
126 |
127 | const filteredData = data
128 | .filter((datum, index) =>
129 | checkParent(datum.block_id, index, layoutSummary, 'parent')
130 | )
131 | .filter((datum, index) =>
132 | checkNumber({position: datum.position}, index)
133 | )
134 |
135 | return buildOutput(filteredData)
136 | }
137 |
138 | export function parseChordData (data, layoutSummary) {
139 | if (data.length === 0) {
140 | return {data: [], meta: {min: null, max: null}}
141 | }
142 |
143 | const formatedData = data
144 | .filter((datum, index) => {
145 | if (datum.source) {
146 | return checkParent(datum.source.id, index, layoutSummary, 'sourceId')
147 | }
148 | logger.warn(`No source for data at index ${index}`)
149 | return false
150 | })
151 | .filter((datum, index) => {
152 | if (datum.target) {
153 | return checkParent(datum.target.id, index, layoutSummary, 'targetId')
154 | }
155 | logger.warn(`No target for data at index ${index}`)
156 | return false
157 | })
158 | .filter((datum, index) => checkNumber({
159 | sourceStart: datum.source.start,
160 | sourceEnd: datum.source.end,
161 | targetStart: datum.target.start,
162 | targetEnd: datum.target.end,
163 | value: datum.value || 1
164 | }, index)
165 | )
166 |
167 | return {
168 | data: formatedData,
169 | meta: {
170 | min: min(formatedData, (d) => d.value),
171 | max: max(formatedData, (d) => d.value)
172 | }
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/src/circos/tracks/Track.js:
--------------------------------------------------------------------------------
1 | import {registerTooltip} from '../behaviors/tooltip'
2 | import {dispatch} from 'd3-dispatch'
3 | import {arc} from 'd3-shape'
4 | import {select, event} from 'd3-selection'
5 | import {getConf} from '../config-utils'
6 | import {buildScale} from '../utils'
7 | import {buildColorValue} from '../colors'
8 | import {renderAxes} from '../axes'
9 |
10 | /**
11 | * Abstract class used by all tracks
12 | **/
13 | export default class Track {
14 | constructor (instance, conf, defaultConf, data, dataParser) {
15 | this.dispatch = dispatch('mouseover', 'mouseout')
16 | this.parseData = dataParser
17 | this.loadData(data, instance)
18 | this.conf = getConf(conf, defaultConf, this.meta, instance)
19 | this.conf.colorValue = buildColorValue(
20 | this.conf.color,
21 | this.conf.cmin,
22 | this.conf.cmax,
23 | this.conf.logScale,
24 | this.conf.logScaleBase
25 | )
26 | this.scale = buildScale(
27 | this.conf.cmin,
28 | this.conf.cmax,
29 | this.conf.outerRadius - this.conf.innerRadius,
30 | this.conf.logScale,
31 | this.conf.logScaleBase
32 | )
33 | }
34 |
35 | loadData (data, instance) {
36 | const result = this.parseData(data, instance._layout.summary())
37 | this.data = result.data
38 | this.meta = result.meta
39 | }
40 |
41 | render (instance, parentElement, name) {
42 | let track = parentElement.select('.' + name)
43 | if(track.empty()) {
44 | track = parentElement.append('g')
45 | .attr('class', name)
46 | .attr('z-index', this.conf.zIndex)
47 | }
48 |
49 | // parentElement.select('.' + name).remove()
50 |
51 | const datumContainer = this.renderBlock(track, this.data, instance._layout, this.conf)
52 | if (this.conf.axes && this.conf.axes.length > 0) {
53 | renderAxes(datumContainer, this.conf, instance, this.scale)
54 | }
55 | const selection = this.renderDatum(datumContainer, this.conf, instance._layout)
56 | if (this.conf.tooltipContent) {
57 | registerTooltip(this, instance, selection, this.conf)
58 | }
59 | selection.on('mouseover', (d, i) => {
60 | this.dispatch.call('mouseover', this, d)
61 | if (this.conf.tooltipContent) {
62 | instance.clipboard.attr('value', this.conf.tooltipContent(d))
63 | }
64 | })
65 | selection.on('mouseout', (d, i) => {
66 | this.dispatch.call('mouseout', this, d)
67 | })
68 |
69 | Object.keys(this.conf.events).forEach((eventName) => {
70 | const conf = this.conf
71 | selection.on(eventName, function (d, i, nodes) { conf.events[eventName](d, i, nodes, event) })
72 | })
73 |
74 | return this
75 | }
76 |
77 | renderBlock (parentElement, data, layout, conf) {
78 | let block = parentElement.selectAll('.block')
79 | .data(data, d=>d.key)
80 | block.enter().append('g')
81 | .attr('id', (d)=> {
82 | return `block-${d.key}`
83 | })
84 | .attr('class', 'block')
85 | block.exit().remove()
86 |
87 | if (conf.backgrounds) {
88 | block.selectAll('.background')
89 | .data((d) => {
90 | return conf.backgrounds.map((background) => {
91 | return {
92 | start: background.start || conf.cmin,
93 | end: background.end || conf.cmax,
94 | angle: layout.blocks[d.key].end - layout.blocks[d.key].start,
95 | color: background.color,
96 | opacity: background.opacity
97 | }
98 | })
99 | })
100 | .enter().append('path')
101 | .attr('class', 'background')
102 | .attr('fill', (background) => background.color)
103 | .attr('opacity', (background) => background.opacity || 1)
104 | .attr('d', arc()
105 | .innerRadius((background) => {
106 | return conf.direction === 'in'
107 | ? conf.outerRadius - this.scale(background.start)
108 | : conf.innerRadius + this.scale(background.start)
109 | })
110 | .outerRadius((background) => {
111 | return conf.direction === 'in'
112 | ? conf.outerRadius - this.scale(background.end)
113 | : conf.innerRadius + this.scale(background.end)
114 | })
115 | .startAngle(0)
116 | .endAngle((d) => d.angle)
117 | )
118 | }
119 | return parentElement.selectAll('.block').data(data, d=>d.key).attr(
120 | 'transform',
121 | (d) => `rotate(${layout.blocks[d.key].start * 360 / (2 * Math.PI)})`
122 | )
123 | }
124 |
125 | theta (position, block) {
126 | return position / block.len * (block.end - block.start)
127 | }
128 |
129 | x (d, layout, conf) {
130 | const height = this.scale(d.value)
131 | const r = conf.direction === 'in'
132 | ? conf.outerRadius - height : conf.innerRadius + height
133 |
134 | const angle = this.theta(d.position, layout.blocks[d.block_id]) - Math.PI / 2
135 | return r * Math.cos(angle)
136 | }
137 |
138 | y (d, layout, conf) {
139 | const height = this.scale(d.value)
140 | const r = conf.direction === 'in'
141 | ? conf.outerRadius - height : conf.innerRadius + height
142 |
143 | const angle = this.theta(d.position, layout.blocks[d.block_id]) - Math.PI / 2
144 | return r * Math.sin(angle)
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/src/circos/axes.test.js:
--------------------------------------------------------------------------------
1 | import { describe, it } from 'mocha'
2 | import chai from 'chai'
3 | import { spy } from 'sinon'
4 | import sinonChai from 'sinon-chai'
5 | import jsdom from 'mocha-jsdom'
6 | import forEach from 'lodash/forEach'
7 | import { _buildAxesData } from './axes'
8 | import { select, selectAll } from 'd3-selection'
9 | import Circos from './circos'
10 |
11 | const expect = chai.expect
12 | chai.use(sinonChai)
13 |
14 | describe('Axes', () => {
15 | describe('_buildAxesData', () => {
16 | it('should log an warning if no position and spacing are defined', () => {
17 | spy(console, 'warn')
18 | const axes = _buildAxesData({
19 | axes: [{color: 'red'}]
20 | })
21 | expect(console.warn).to.have.been.called.calledOnce
22 | expect(axes.length).to.equal(0)
23 | console.warn.restore()
24 | })
25 |
26 | it('should return the axe group if position attribute is defined', () => {
27 | const axes = _buildAxesData({
28 | axes: [
29 | {
30 | position: 12
31 | }
32 | ],
33 | opacity: 1
34 | })
35 | expect(axes.length).to.equal(1)
36 | expect(axes[0].value).to.equal(12)
37 | expect(axes[0].opacity).to.equal(1)
38 | expect(axes[0].color).to.equal('#d3d3d3')
39 | expect(axes[0].thickness).to.equal(1)
40 | })
41 |
42 | forEach([
43 | {
44 | spacing: 2,
45 | min: 10,
46 | max: 20,
47 | expected: [10, 12, 14, 16, 18]
48 | },
49 | {
50 | spacing: 2,
51 | start: 14,
52 | min: 10,
53 | max: 20,
54 | expected: [14, 16, 18]
55 | },
56 | {
57 | spacing: 2,
58 | end: 14,
59 | min: 10,
60 | max: 20,
61 | expected: [10, 12]
62 | }
63 | ], (dataset) => {
64 | it('should return a range of axes according to spacing, start and end attributes', () => {
65 | const axes = _buildAxesData({
66 | axes: [
67 | {
68 | spacing: dataset.spacing,
69 | start: dataset.start,
70 | end: dataset.end
71 | }
72 | ],
73 | cmin: dataset.min,
74 | cmax: dataset.max
75 | })
76 | expect(axes.length).to.equal(dataset.expected.length)
77 | forEach(axes, (axis, i) => {
78 | expect(axis.value).to.equal(dataset.expected[i])
79 | })
80 | })
81 | })
82 |
83 | it('should use axe group color, opacity and thickness if defined', () => {
84 | const axes = _buildAxesData({
85 | axes: [
86 | {
87 | position: 12,
88 | opacity: 0.5,
89 | color: 'red',
90 | thickness: 3
91 | }
92 | ],
93 | opacity: 1
94 | })
95 | expect(axes.length).to.equal(1)
96 | expect(axes[0].value).to.equal(12)
97 | expect(axes[0].opacity).to.equal(0.5)
98 | expect(axes[0].color).to.equal('red')
99 | expect(axes[0].thickness).to.equal(3)
100 | })
101 |
102 | it('should create range axes and simple axis', () => {
103 | const axes = _buildAxesData({
104 | axes: [
105 | {
106 | position: 12
107 | },
108 | {
109 | spacing: 2
110 | }
111 | ],
112 | opacity: 1,
113 | cmin: 10,
114 | cmax: 20
115 | })
116 | expect(axes.length).to.equal(6)
117 | })
118 | })
119 |
120 | describe('renderAxes', function () {
121 | jsdom()
122 | const configuration = {
123 | min: 10,
124 | max: 20,
125 | axes: [
126 | {
127 | position: 2,
128 | opacity: 0.5,
129 | thickness: 2,
130 | color: 'grey'
131 | }
132 | ]
133 | }
134 | beforeEach(function () {
135 | document.body.innerHTML = ''
136 | this.instance = new Circos({container: '#chart', width: 350, height: 350})
137 | .layout([{id: 'january', len: 31}, {id: 'february', len: 28}])
138 | })
139 |
140 | forEach([
141 | {
142 | track: 'line',
143 | data: [
144 | {block_id: 'january', position: 1, value: 1},
145 | {block_id: 'february', position: 2, value: 4}
146 | ]
147 | },
148 | {
149 | track: 'scatter',
150 | data: [
151 | {block_id: 'january', position: 1, value: 1},
152 | {block_id: 'february', position: 2, value: 4}
153 | ]
154 | },
155 | {
156 | track: 'histogram',
157 | data: [
158 | {block_id: 'january', start: 1, end: 2, value: 1},
159 | {block_id: 'february', start: 1, end: 2, value: 4}
160 | ]
161 | }
162 | ], (dataset) => {
163 | it(`should render the axes for track ${dataset.track}`, function () {
164 | this.instance[dataset.track]('track1', dataset.data, configuration).render()
165 | const axes = selectAll('.axis')
166 | expect(axes.size()).to.equal(2)
167 | forEach(axes.nodes(), (axisNode, i) => {
168 | const axis = select(axisNode)
169 | expect(axis.attr('stroke')).to.equal('grey')
170 | expect(axis.attr('stroke-width')).to.equal('2')
171 | expect(axis.attr('opacity')).to.equal('0.5')
172 | })
173 | })
174 | })
175 | })
176 | })
177 |
--------------------------------------------------------------------------------
/mock/server.js:
--------------------------------------------------------------------------------
1 | const express = require('express')
2 | const _ = require('lodash')
3 | const {
4 | jsonSend
5 | } = require('./helpers')
6 |
7 | const storeTpl = {
8 | "id": 1,
9 | "address": "172.16.10.61:20160",
10 | "state_name": "Up",
11 | "capacity": 5368709120, // in bytes.
12 | "available": 5279425783, // in bytes.
13 | "leader_count": 42,
14 | "leader_weight": 1,
15 | "leader_score": 4303,
16 | "leader_size": 4303,
17 | "region_count": 129,
18 | "region_weight": 1,
19 | "region_score": 13446,
20 | "region_size": 13446,
21 | "start_ts": "2017-12-06T20:50:58+08:00",
22 | "last_heartbeat_ts": "2017-12-12T13:30:19.983311202+08:00",
23 | "uptime": "136h39m21.983311202s",
24 |
25 |
26 | "hot_write_flow": null,
27 | "hot_write_region_flows": null,
28 | "hot_read_flow": null,
29 | "hot_read_region_flows": null
30 | }
31 |
32 | let timeFrameIdx = 0
33 | let timeFrame = []
34 | let start = 50,
35 | delta = 0.75,
36 | space = 1000,
37 | xDelta = 0.6
38 | _.range(2).forEach(() => {
39 | timeFrame.push({
40 | storeCount: 3,
41 | base: {
42 | storeSpace: space,
43 | storeUsage: start,
44 | },
45 | nodes: {}
46 | })
47 | })
48 | timeFrame.push({
49 | storeCount: 4,
50 | base: {
51 | storeSpace: space,
52 | storeUsage: start,
53 | },
54 | nodes: {
55 | 3: {
56 | storeUsage: delta*3,
57 | }
58 | }
59 | })
60 | timeFrame.push({
61 | storeCount: 4,
62 | base: {
63 | storeSpace: space,
64 | storeUsage: start,
65 | },
66 | nodes: {
67 | 3: {
68 | storeUsage: delta*6,
69 | }
70 | }
71 | })
72 |
73 | timeFrame.push({
74 | storeCount: 5,
75 | base: {
76 | storeSpace: space,
77 | storeUsage: start,
78 | },
79 | nodes: {
80 | 3: {
81 | storeUsage: delta*9,
82 | },
83 | 4: {
84 | storeUsage: delta*3,
85 | }
86 | }
87 | })
88 |
89 | _.range(12).forEach((i) => {
90 | ++i
91 | timeFrame.push({
92 | storeCount: 5,
93 | base: {
94 | storeSpace: 1000,
95 | },
96 | nodes: {
97 | 0: {
98 | storeUsage: start - i * delta
99 | },
100 | 1: {
101 | storeUsage: start - i * delta
102 | },
103 | 2: {
104 | storeUsage: start - i * delta
105 | },
106 | 3: {
107 | storeUsage: delta * (i + 4) * 3
108 | },
109 | 4: {
110 | storeUsage: delta * (i + 2) * 3
111 | },
112 | }
113 | })
114 | })
115 |
116 | function genFakeV2() {
117 | if (timeFrameIdx >= timeFrame.length) {
118 | timeFrameIdx = timeFrame.length -1
119 | }
120 |
121 | const frame = timeFrame[timeFrameIdx]
122 | let ret = _.range(frame.storeCount).map(i => {
123 | const conf = Object.assign({}, frame.base, frame.nodes[i])
124 | let data = genFakeStore(conf)
125 | data.id = i
126 | return data
127 | })
128 | ++timeFrameIdx
129 | return ret
130 | }
131 |
132 | function genFakeHistoryV2() {
133 | let entries = []
134 | const count = space * delta / 100
135 | const val = () => _.random(count * 0.9, count * 1.1) / 3
136 | if (timeFrameIdx >= timeFrame.length) {
137 | timeFrameIdx = timeFrame.length -1
138 | }
139 | const frame = timeFrame[timeFrameIdx]
140 |
141 | _.range(3).forEach((i) => {
142 | _.range(frame.storeCount - 3).forEach(x=>{
143 | entries.push({
144 | from: i,
145 | to: 3+x,
146 | kind: 'region',
147 | count: val()
148 | })
149 | entries.push({
150 | from: i,
151 | to: 3+x,
152 | kind: 'leader',
153 | count: val() / 3
154 | })
155 | })
156 | })
157 | return {
158 | entries
159 | }
160 | }
161 |
162 |
163 |
164 | let trendSeed = 0
165 |
166 | function genFakeStore(conf = {
167 | storeSpace: 480,
168 | storeUsage: 30
169 | }) {
170 | const {
171 | storeSpace,
172 | storeUsage
173 | } = conf
174 | let writeP = 0.9,
175 | writeC = 0.03,
176 | readP = 0.9,
177 | readC = 0.03
178 |
179 | let data = _.cloneDeep(storeTpl)
180 | data.capacity = vibrate(storeSpace, 0.001) * 64 * 1000000
181 | data.available = vibrate(storeSpace * (100 - storeUsage) / 100, 0.001) * 64 * 1000000
182 | data.region_count = vibrate(storeSpace * storeUsage / 100, 0.001)
183 | data.leader_count = vibrate(storeSpace * storeUsage / 100 / 3, 0.001)
184 |
185 | let s = Math.random()
186 | if (s < writeP) {
187 | data.hot_write_flow = _.random(8000, 11000)
188 | data.hot_write_region_flows = _.range(Math.ceil(writeC*data.region_count)).map(() => _.random(2000, 4000))
189 | }
190 |
191 | if (s < readP) {
192 | data.hot_read_flow = _.random(8000, 12000)
193 | data.hot_read_region_flows = _.range(Math.ceil(readC*data.region_count)).map(() => _.random(1400, 3300))
194 | }
195 |
196 | // data.region_count = trend(storeSpace * storeUsage / 100, (i % 2 === 1) ? 0.01: -0.01)
197 | // data.leader_count = trend(storeSpace * storeUsage /100/3, (i % 2 === 1) ? 0.02: -0.02)
198 | return data
199 |
200 | function vibrate(base, diffRatio, isInt = true) {
201 | let val = base * (1 + _.random(-diffRatio, diffRatio))
202 | return isInt ? Math.ceil(val) : val
203 | }
204 |
205 | function trend(base, diff, isInt = true) {
206 | let val = base * (1 + trendSeed * diff)
207 | ++trendSeed
208 | return isInt ? Math.ceil(val) : val
209 | }
210 | }
211 |
212 | function genFakeData(storeCount = 5, idx = 1, delta=5) {
213 | // store count, store space size[300GB/64MB], store usage[30%],
214 | // hotspot - temp high
215 | // history - add store, remove store,
216 | return _.range(storeCount).map(i => {
217 | let data = genFakeStore({
218 | storeSpace: 480,
219 | storeUsage: 20+idx*delta
220 | })
221 | data.id = i
222 | return data
223 | })
224 | }
225 |
226 |
227 | function genFakeHistory(stores) {
228 | const leaderP = 0.04,
229 | regionP = 0.05,
230 | jointP = 0.15
231 | let entries = []
232 | stores.forEach(i => {
233 | stores.forEach(ii => {
234 | if (i.id == ii.id) return
235 | let s = Math.random()
236 | if (s < jointP) {
237 | if (s > jointP / 2) {
238 | entries.push({
239 | from: i.id,
240 | to: ii.id,
241 | kind: 'region',
242 | count: Math.ceil(i.region_count * regionP)
243 | })
244 | } else {
245 | entries.push({
246 | from: i.id,
247 | to: ii.id,
248 | kind: 'leader',
249 | count: Math.ceil(i.leader_count * leaderP)
250 | })
251 | }
252 | }
253 | })
254 | })
255 | return {
256 | "start": 1513086582, // start time of the history.
257 | "end": 1513086616, // end time of the history.
258 | entries,
259 | }
260 | }
261 |
262 | function mount(router) {
263 | router.get('/', jsonSend({}))
264 |
265 | let simpleIdx = 1
266 | // router.get('/trend', (req, res)=>{
267 | // let stores = genFakeData(5, simpleIdx, 4)
268 | // let history = genFakeHistory(stores)
269 | // ++simpleIdx
270 | // res.json({
271 | // stores,
272 | // history
273 | // })
274 | // })
275 |
276 | router.get('/trend', (req, res) => {
277 | let stores = genFakeV2()
278 | let history
279 | if (timeFrameIdx < 2) {
280 | history = {
281 | entries: []
282 | } // genFakeHistory(stores)
283 | } else {
284 | history = genFakeHistoryV2()
285 | }
286 | res.json({
287 | stores,
288 | history
289 | })
290 | })
291 |
292 | router.get('/hotspot/regions/write', jsonSend({}))
293 | }
294 |
295 | // wheather start as singleton server
296 | const isCLI = require.main === module
297 | if (isCLI) {
298 | const app = express()
299 | mountToApp(app, '/pd/api/v1')
300 | const opt = {
301 | port: process.env.MOCK_PORT || 9000,
302 | host: '0.0.0.0',
303 | // backlog, to specify the maximum length of the queue of pending connections, default value of this parameter is 511
304 | }
305 | app.listen(opt, (e) => {
306 | console.log('starting server at ', opt)
307 | })
308 | }
309 |
310 | exports = module.exports = mountToApp
311 |
312 | function mountToApp(app, prefix = '/') {
313 | const router = express.Router()
314 | mount(router)
315 | app.use(prefix, router)
316 | console.log('Mount router at: ', prefix)
317 | }
318 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
529 |
530 |
612 |
--------------------------------------------------------------------------------