├── 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 | ![](./demo.gif) 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 | 9 | 10 | 529 | 530 | 612 | --------------------------------------------------------------------------------