diff --git a/.gitignore b/.gitignore index 52a5975fc6..dd3b1ae5ef 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ plottable.js plottable.js.map tests.js tests.js.map +examples/*.js +examples/*.js.map diff --git a/Gruntfile.js b/Gruntfile.js index 81556e809d..cfdff01a2e 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -23,7 +23,8 @@ module.exports = function(grunt) { }, files: [ { src: ["src/*.ts"], dest: "plottable.js" }, - { src: ["test/*.ts"], dest: "test/tests.js" } + { src: ["test/*.ts"], dest: "test/tests.js" }, + { src: ["examples/*.ts"], dest: "examples/examples.js"} ] } }, @@ -35,7 +36,8 @@ module.exports = function(grunt) { "files": [ "Gruntfile.js", "src/*.ts", - "test/*.ts" + "test/*.ts", + "examples/*.ts" ] } } diff --git a/examples/demo-day-crazy.css b/examples/demo-day-crazy.css new file mode 100644 index 0000000000..00304266cf --- /dev/null +++ b/examples/demo-day-crazy.css @@ -0,0 +1,34 @@ +.demo-table-title text { + text-decoration: line-through; + fill: yellow; + font-family: Times; +} + +.scatterplot-title text { + font-size: 24pt; + fill: magenta; + font-family: monospace; +} + +.histogram-title text { + font-size: 24pt; + fill: cyan; + font-family: cursive; +} + +rect { + fill: green; +} + +circle { + fill: orange; +} + +.selected-point { + fill: black; +} + +.drag-box { + fill: red; + opacity: 0.5; +} diff --git a/examples/demo-day-crazy.html b/examples/demo-day-crazy.html new file mode 100644 index 0000000000..ee114e7d2a --- /dev/null +++ b/examples/demo-day-crazy.html @@ -0,0 +1,20 @@ + + + + + + + +


+ +


+ + + + + + + + + + diff --git a/examples/demo-day.css b/examples/demo-day.css new file mode 100644 index 0000000000..31c3578c3e --- /dev/null +++ b/examples/demo-day.css @@ -0,0 +1,14 @@ +.demo-table-title text { + text-decoration: underline; +} + +.scatterplot-title text { + font-size: 24pt; +} + +.histogram-title text { + font-size: 24pt; + +.axis-label text { + font-size: 12pt; +} diff --git a/examples/demo-day.html b/examples/demo-day.html new file mode 100644 index 0000000000..486136301f --- /dev/null +++ b/examples/demo-day.html @@ -0,0 +1,20 @@ + + + + + + + +


+ +


+ + + + + + + + + + diff --git a/src/demo.ts b/examples/demo.ts similarity index 61% rename from src/demo.ts rename to examples/demo.ts index ef3e0bd7dd..914a5b7a3f 100644 --- a/src/demo.ts +++ b/examples/demo.ts @@ -1,22 +1,17 @@ /// /// +/// + +/// +/// +/// +/// +/// +/// -/// -/// -/// - -function makeRandomData(numPoints, scaleFactor=1): IDataset { - var data = []; - for (var i = 0; i < numPoints; i++) { - var x = Math.random(); - var r = {x: x, y: (x + x * Math.random()) * scaleFactor} - data.push(r); - } - data = _.sortBy(data, (d) => d.x); - return {"data": data, "seriesName": "random-data"}; -} +if (( window).demoName == "demo1") { // make a regular table with 1 axis on bottom, 1 axis on left, renderer in center var svg1 = d3.select("#svg1"); @@ -31,7 +26,7 @@ var basicTable = new Table([[renderArea, yAxis], [xAxis, null]]) basicTable.anchor(svg1); basicTable.computeLayout(); basicTable.render(); -new DragZoomInteraction(renderArea.hitBox, [xAxis, yAxis, renderArea], xScale, yScale); +new PanZoomInteraction(renderArea, [xAxis, yAxis, renderArea], xScale, yScale); @@ -55,6 +50,8 @@ var t3 = makeBasicChartTable(); var t4 = makeBasicChartTable(); var metaTable = new Table([[t1, t2], [t3, t4]]); +metaTable.rowPadding = 5; +metaTable.colPadding = 5; metaTable.anchor(svg2); svg2.attr("width", 800).attr("height", 600); metaTable.computeLayout(); @@ -72,7 +69,6 @@ function makeMultiAxisChart() { var data = makeRandomData(30); var renderArea = new LineRenderer(data, xScale, yScale); var rootTable = new Table([[renderArea, rightAxesTable], [xAxis, null]]) - console.log(rootTable); return rootTable; } @@ -99,20 +95,22 @@ function makeSparklineMultichart() { var row1: Component[] = [leftAxesTable, renderer1, rightAxesTable]; var yScale2 = new LinearScale(); var leftAxis = new YAxis(yScale2, "left"); - var data2 = makeRandomData(100, 100000); + leftAxis.xAlignment = "RIGHT"; + var data2 = makeRandomData(1000, 100000); var renderer2 = new CircleRenderer(data2, xScale1, yScale2); + var toggleClass = function() {return !d3.select(this).classed("selected-point")}; + var cb = (s) => s.classed("selected-point", toggleClass); + var areaInteraction = new AreaInteraction(renderer2, null, cb); var row2: Component[] = [leftAxis, renderer2, null]; var bottomAxis = new XAxis(xScale1, "bottom"); var row3: Component[] = [null, bottomAxis, null]; + var xScaleSpark = new LinearScale(); var yScaleSpark = new LinearScale(); - var sparkline = new LineRenderer(data2, xScale1, yScaleSpark); + var sparkline = new LineRenderer(data2, xScaleSpark, yScaleSpark); sparkline.rowWeight(0.25); var row4 = [null, sparkline, null]; + var zoomInteraction = new BrushZoomInteraction(sparkline, xScale1, yScale2); var multiChart = new Table([row1, row2, row3, row4]); - // multiChart.xMargin = 0; - // multiChart.yMargin = 0; - // multiChart.xPadding = 0; - // multiChart.yPadding = 0; return multiChart; } @@ -134,10 +132,47 @@ multichart.render(); // var bottomAxes = iterate(bottom, () => new xAxis(yScale, "bottom")) // } -function iterate(n: number, fn: () => any) { - var out = []; - for (var i=0; i + + + + + + +

Bar Renderer

+


+ + +

Basic TSC

+


+

Chartbag of timeseriescharts

+


+

TSC with 2 axes

+


+

TSC with subplots, varying # of axes, and sparkline

+


+


+ + + + + + + + + + diff --git a/examples/demoDay.ts b/examples/demoDay.ts new file mode 100644 index 0000000000..4f9dfee525 --- /dev/null +++ b/examples/demoDay.ts @@ -0,0 +1,140 @@ +/// + +/// +/// +/// +/// +/// +/// +/// + +if (( window).demoName === "demo-day") { +var N_BINS = 25; +function makeScatterPlotWithSparkline(data) { + var s: any = {}; + s.xScale = new LinearScale(); + s.yScale = new LinearScale(); + s.leftAxis = new YAxis(s.yScale, "left"); + var leftAxisTable = new Table([[new AxisLabel("y", "vertical-left"), s.leftAxis]]); + leftAxisTable.colWeight(0); + s.xAxis = new XAxis(s.xScale, "bottom"); + var xAxisTable = new Table([[s.xAxis], [new AxisLabel("x")]]); + xAxisTable.rowWeight(0); + + s.renderer = new CircleRenderer(data, s.xScale, s.yScale, null, null, 1.5); + s.xSpark = new LinearScale(); + s.ySpark = new LinearScale(); + s.sparkline = new CircleRenderer(data, s.xSpark, s.ySpark, null, null, 0.5); + s.sparkline.rowWeight(0.25); + var r1 = [leftAxisTable, s.renderer]; + var r2 = [null, xAxisTable]; + var r3 = [null, s.sparkline]; + s.table = new Table([r1,r2,r3]); + return s; +} + +function makeHistograms(data: any[]) { + var h: any = {}; + var xExtent = d3.extent(data, (d) => d.x); + h.xScale1 = new LinearScale().domain(xExtent); + h.yScale1 = new LinearScale(); + h.bin1 = makeBinFunction((d) => d.x, xExtent, N_BINS); + var data1 = h.bin1(data); + var ds1 = {data: data1, seriesName: "xVals"} + h.renderer1 = new BarRenderer(ds1, h.xScale1, h.yScale1); + h.xAxis1 = new XAxis(h.xScale1, "bottom"); + h.yAxis1 = new YAxis(h.yScale1, "right"); + var labelX1Table = new Table([[h.xAxis1], [new AxisLabel("X values")]]); + labelX1Table.rowWeight(0); + var labelY1Table = new Table([[h.yAxis1, new AxisLabel("Counts", "vertical-right")]]); + labelY1Table.colWeight(0); + var table1 = new Table([[h.renderer1, labelY1Table], [labelX1Table, null]]); + + var yExtent = d3.extent(data, (d) => d.y); + h.xScale2 = new LinearScale().domain(yExtent); + h.yScale2 = new LinearScale(); + h.bin2 = makeBinFunction((d) => d.y, yExtent, N_BINS); + var data2 = h.bin2(data); + var ds2 = {data: data2, seriesName: "yVals"} + h.renderer2 = new BarRenderer(ds2, h.xScale2, h.yScale2); + h.xAxis2 = new XAxis(h.xScale2, "bottom"); + h.yAxis2 = new YAxis(h.yScale2, "right"); + var labelX2Table = new Table([[h.xAxis2], [new AxisLabel("Y values")]]); + labelX2Table.rowWeight(0); + var labelY2Table = new Table([[h.yAxis2, new AxisLabel("Counts", "vertical-right")]]); + labelY2Table.colWeight(0); + var table2 = new Table([[h.renderer2, labelY2Table], [labelX2Table, null]]); + + h.table = new Table([[table1], [table2]]); + h.table.rowPadding = 5; + h.table.colPadding = 5; + return h; +} + +function makeScatterHisto(data) { + var s = makeScatterPlotWithSparkline(data); + var h = makeHistograms(data.data); + var r = [s.table, h.table]; + var titleRow = [ new TitleLabel("Random Data").classed("scatterplot-title", true), + new TitleLabel("Histograms").classed("histogram-title", true) ]; + var chartTable = new Table([titleRow, r]); + chartTable.colPadding = 10; + var table = new Table([[new TitleLabel("Glorious Demo Day Demo of Glory").classed("demo-table-title", true)], [chartTable]]); + + return {table: table, s: s, h: h}; +} + +function coordinator(chart: any, dataset: IDataset) { + var scatterplot = chart.s; + var histogram = chart.h; + chart.c = {}; + + var lastSelection = null; + var selectionCallback = (selection: D3.Selection) => { + if (lastSelection != null) lastSelection.classed("selected-point", false); + selection.classed("selected-point", true); + lastSelection = selection; + } + + var data = dataset.data; + // var lastSelectedData = null; + var dataCallback = (selectedIndices: number[]) => { + var selectedData = grabIndices(data, selectedIndices); + // selectedData.forEach((d) => d.selected = true); + // if (lastSelectedData != null) lastSelectedData.forEach((d) => d.selected = false); + // lastSelectedData = selectedData; + var xBins = histogram.bin1(selectedData); + var yBins = histogram.bin2(selectedData); + chart.c.xBins = xBins; + chart.c.yBins = yBins; + histogram.renderer1.data({seriesName: "xBins", data: xBins}) + histogram.renderer2.data({seriesName: "yBins", data: yBins}) + histogram.renderer1.render(); + histogram.renderer2.render(); + }; + var areaInteraction = new AreaInteraction(scatterplot.renderer, null, selectionCallback, dataCallback); + var zoomCallback = (indices) => {areaInteraction.clearBox(); dataCallback(indices)}; + chart.c.zoom = new BrushZoomInteraction(scatterplot.sparkline, scatterplot.xScale, scatterplot.yScale, zoomCallback); +} + +function grabIndices(itemsToGrab: any[], indices: number[]) { + return indices.map((i) => itemsToGrab[i]); +} +var clump1 = makeNormallyDistributedData(300, -10, 5, 7, 1); +var clump2 = makeNormallyDistributedData(300, 2, 0.5, 3, 3); +var clump3 = makeNormallyDistributedData(30, 5, 10, -3, 9); +var clump4 = makeNormallyDistributedData(200, -25, 1, 20, 5); + +var clumpData = clump1.concat(clump2, clump3, clump4); +var dataset = {seriesName: "clumpedData", data: clumpData}; + +var chartSH = makeScatterHisto(dataset); + +coordinator(chartSH, dataset); + +var svg = d3.select("#table"); +chartSH.table.anchor(svg); +chartSH.table.computeLayout(); +chartSH.table.render(); + +} diff --git a/examples/exampleUtil.ts b/examples/exampleUtil.ts new file mode 100644 index 0000000000..9a31034a68 --- /dev/null +++ b/examples/exampleUtil.ts @@ -0,0 +1,69 @@ +function makeRandomData(numPoints, scaleFactor=1): IDataset { + var data = []; + for (var i = 0; i < numPoints; i++) { + var x = Math.random(); + var r = {x: x, y: (x + x * Math.random()) * scaleFactor} + data.push(r); + } + data = _.sortBy(data, (d) => d.x); + return {"data": data, "seriesName": "random-data"}; +} + +function makeNormallyDistributedData(n=100, xMean?, xStdDev?, yMean?, yStdDev?) { + var results = []; + var x = d3.random.normal(xMean, xStdDev); + var y = d3.random.normal(yMean, yStdDev); + for (var i=0; i binByVal(d, accessor, range, nBins); +} + +function binByVal(data: any[], accessor: IAccessor, range=[0,100], nBins=10) { + if (accessor == null) {accessor = (d) => d.x}; + var min = range[0]; + var max = range[1]; + var spread = max-min; + var binBeginnings = _.range(nBins).map((n) => min + n * spread / nBins); + var binEndings = _.range(nBins) .map((n) => min + (n+1) * spread / nBins); + var counts = new Array(nBins); + _.range(nBins).forEach((b, i) => counts[i] = 0); + data.forEach((d) => { + var v = accessor(d); + var found = false; + for (var i=0; i { + var bin: any = {}; + bin.x = binBeginnings[i]; + bin.x2 = binEndings[i]; + bin.y = count; + return bin; + }) + return bins; +} +function makeRandomBucketData(numBuckets: number, bucketWidth: number, maxValue = 10): IDataset { + var data = []; + for (var i=0; i < numBuckets; i++) { + data.push({ + x: i * bucketWidth, + x2: (i+1) * bucketWidth, + y: Math.round(Math.random() * maxValue) + }); + } + return { + "data": data, + "seriesName": "random-buckets" + }; +} diff --git a/examples/sparkline-demo.html b/examples/sparkline-demo.html new file mode 100644 index 0000000000..5070e53026 --- /dev/null +++ b/examples/sparkline-demo.html @@ -0,0 +1,18 @@ + + + + + + +

Basic TSC

+


+ + + + + + + + + + diff --git a/examples/sparklineDemo.ts b/examples/sparklineDemo.ts new file mode 100644 index 0000000000..7b6f8fcc03 --- /dev/null +++ b/examples/sparklineDemo.ts @@ -0,0 +1,43 @@ +/// + +/// +/// +/// +/// +/// +/// +/// + +if (( window).demoName === "sparkline-demo") { + +var yScale = new LinearScale(); +var xScale = new LinearScale(); +var left = new YAxis(yScale, "left"); +var data = makeRandomData(1000, 200); +var renderer = new CircleRenderer(data, xScale, yScale); +var bottomAxis = new XAxis(xScale, "bottom"); +var xSpark = new LinearScale(); +var ySpark = new LinearScale(); +var sparkline = new LineRenderer(data, xSpark, ySpark); +sparkline.rowWeight(0.3); + +var r1: Component[] = [left, renderer]; +var r2: Component[] = [null, bottomAxis]; +var r3: Component[] = [null, sparkline]; + +var chart = new Table([r1, r2, r3]); +chart.xMargin = 10; +chart.yMargin = 10; + +var brushZoom = new BrushZoomInteraction(sparkline, xScale, yScale); +var toggleClass = function() {return !d3.select(this).classed("selected-point")}; +var cb = (s) => s.classed("selected-point", toggleClass); +var areaInteraction = new AreaInteraction(renderer, null, cb); + +var svg = d3.select("#table"); +chart.anchor(svg); +chart.computeLayout(); +chart.render(); + + +} diff --git a/examples/tsc-demo.html b/examples/tsc-demo.html new file mode 100644 index 0000000000..fae6819b04 --- /dev/null +++ b/examples/tsc-demo.html @@ -0,0 +1,18 @@ + + + + + + +

Basic TSC

+


+ + + + + + + + + + diff --git a/examples/tscDemo.ts b/examples/tscDemo.ts new file mode 100644 index 0000000000..8775cfabb1 --- /dev/null +++ b/examples/tscDemo.ts @@ -0,0 +1,32 @@ +/// + +/// +/// +/// +/// +/// +/// +/// + +if (( window).demoName === "tsc-demo") { + +var yScale = new LinearScale(); +var xScale = new LinearScale(); +var left = new YAxis(yScale, "left"); +var data = makeRandomData(1000, 200); +var renderer = new LineRenderer(data, xScale, yScale); +var bottomAxis = new XAxis(xScale, "bottom"); + +var chart = new Table([[left, renderer] + ,[null, bottomAxis]]); + +var outerTable = new Table([ [new TitleLabel("A Chart")], + [chart] ]) +outerTable.xMargin = 10; +outerTable.yMargin = 10; + +var svg = d3.select("#table"); +outerTable.anchor(svg); +outerTable.computeLayout(); +outerTable.render(); +} diff --git a/index.html b/index.html index 96f162451a..d9c8deee16 100644 --- a/index.html +++ b/index.html @@ -4,18 +4,15 @@ + -

HERE THERE BE TESTS

-

Basic TSC

-


-

Chartbag of timeseriescharts

-


-

TSC with 2 axes

-


-

TSC with subplots, varying # of axes, and sparkline

-


-


- +

Tests

+

The old demo

+

Sparkline demo

+

Demo Day

+

Demo Day With Crazy CSS

+

TSC Demo

(the code) + diff --git a/package.json b/package.json index 6af3715ef5..5387c1a968 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,10 @@ { - "name": "linkedaxisprototype", - "version": "0.0.0", - "description": "Prototype linked axis timeseriescharts", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, + "name": "plottable", + "version": "0.1.0", + "description": "A library for easily creating charts within a grid layout.", "repository": { "type": "git", - "url": "https://github.com/danmane/LinkedChartPrototype.git" + "url": "https://github.com/palantir/plottable.git" }, "author": "daniel mane", "devDependencies": { diff --git a/src/axis.ts b/src/axis.ts index d6327baacd..37c5cec639 100644 --- a/src/axis.ts +++ b/src/axis.ts @@ -32,11 +32,13 @@ class Axis extends Component { this.isXAligned = this.orientation === "bottom" || this.orientation === "top"; this.d3axis = d3.svg.axis().scale(this.scale.scale).orient(this.orientation); if (this.formatter == null) { - this.formatter = d3.format("s3"); + this.formatter = d3.format(".3s"); } this.d3axis.tickFormat(this.formatter); + this.cachedScale = 1; this.cachedTranslate = 0; + this.scale.registerListener(() => this.rescale()); } private transformString(translate: number, scale: number) { @@ -48,7 +50,7 @@ class Axis extends Component { public rowWeight(newVal: number): Component; public rowWeight(newVal?: number): any { if (newVal != null) { - throw new Error("Axis row weight is not settable."); + throw new Error("Row weight cannot be set on Axis."); return this; } else { return 0; @@ -59,7 +61,7 @@ class Axis extends Component { public colWeight(newVal: number): Component; public colWeight(newVal?: number): any { if (newVal != null) { - throw new Error("Axis col weight is not settable."); + throw new Error("Col weight cannot be set on Axis."); return this; } else { return 0; @@ -86,11 +88,19 @@ class Axis extends Component { } else { newDomain = standardOrder ? [new Date(min - extent), new Date(max + extent)] : [new Date(max + extent), new Date(min - extent)]; } - // var copyScale = this.scale.copy().domain(newDomain) - // var ticks = ( copyScale).ticks(30); - // this.d3axis.tickValues(ticks); - // a = [100,0]; extent = -100; 100 - (-100) = 200, 0 - (-100) = 100 - // a = [0,100]; extent = 100; 0 - 100 = -100, 100 - 100 + + // Make tiny-zero representations not look like crap, by rounding them to 0 + if (( this.scale).ticks != null) { + var scale = this.scale; + var nTicks = 10; + var ticks = scale.ticks(nTicks); + var domain = scale.domain(); + var interval = domain[1] - domain[0]; + var cleanTick = (n) => Math.abs(n / interval / nTicks) < 0.0001 ? 0 : n; + ticks = ticks.map(cleanTick); + this.d3axis.tickValues(ticks); + } + this.axisElement.call(this.d3axis); var bbox = ( this.axisElement.node()).getBBox(); if (bbox.height > this.availableHeight || bbox.width > this.availableWidth) { @@ -101,6 +111,8 @@ class Axis extends Component { } public rescale() { + return (this.element != null) ? this.render() : null; + // short circuit, we don't care about perf. var tickTransform = this.isXAligned ? Axis.axisXTransform : Axis.axisYTransform; var tickSelection = this.element.selectAll(".tick"); ( tickSelection).call(tickTransform, this.scale.scale); @@ -108,6 +120,7 @@ class Axis extends Component { } public zoom(translatePair: number[], scale: number) { + return this.render(); //short-circuit, we dont need the performant cleverness for present demo var translate = this.isXAligned ? translatePair[0] : translatePair[1]; if (scale != null && scale != this.cachedScale) { this.cachedTranslate = translate; diff --git a/src/component.ts b/src/component.ts index 93f5b6ca17..4e024923c6 100644 --- a/src/component.ts +++ b/src/component.ts @@ -1,7 +1,13 @@ +/// +/// + class Component { + private static clipPathId = 0; // Used for unique namespacing for the clipPaths public element: D3.Selection; public hitBox: D3.Selection; public boundingBox: D3.Selection; + private clipPathRect: D3.Selection; + private registeredInteractions: Interaction[] = []; private rowWeightVal = 0; private colWeightVal = 0; @@ -13,10 +19,54 @@ class Component { private xOffset : number; private yOffset : number; + private cssClasses: string[] = []; + + public xAlignment = "LEFT"; // LEFT, CENTER, RIGHT + public yAlignment = "TOP"; // TOP, CENTER, BOTTOM + + public classed(cssClass: string): boolean; + public classed(cssClass: string, addClass: boolean): Component; + public classed(cssClass: string, addClass?:boolean): any { + if (addClass == null) { + if (this.element == null) { + return (this.cssClasses.indexOf(cssClass) != -1); + } else { + return this.element.classed(cssClass); + } + } else { + if (this.element == null) { + var classIndex = this.cssClasses.indexOf(cssClass); + if (addClass && classIndex == -1) { + this.cssClasses.push(cssClass); + } else if (!addClass && classIndex != -1) { + this.cssClasses.splice(classIndex, 1); + } + } else { + this.element.classed(cssClass, addClass); + } + return this; + } + } + public anchor(element: D3.Selection) { this.element = element; + this.generateClipPath(); + this.cssClasses.forEach((cssClass: string) => { + this.element.classed(cssClass, true); + }); + this.cssClasses = null; this.hitBox = element.append("rect").classed("hit-box", true); this.boundingBox = element.append("rect").classed("bounding-box", true); + this.registeredInteractions.forEach((r) => r.anchor(this.hitBox)); + } + + public generateClipPath() { + // The clip path will prevent content from overflowing its component space. + var clipPathId = Component.clipPathId++; + this.element.attr("clip-path", "url(#clipPath" + clipPathId + ")"); + this.clipPathRect = this.element.append("clipPath") + .attr("id", "clipPath" + clipPathId) + .append("rect"); } public computeLayout(xOffset?: number, yOffset?: number, availableWidth?: number, availableHeight?: number) { @@ -33,13 +83,53 @@ class Component { throw new Error("You need to pass non-null arguments when calling computeLayout on a non-root node"); } } + if (this.rowWeight() === 0 && this.rowMinimum() !== 0) { + switch (this.yAlignment) { + case "TOP": + break; + case "CENTER": + yOffset += (availableHeight - this.rowMinimum()) / 2; + break; + case "BOTTOM": + yOffset += availableHeight - this.rowMinimum(); + break; + default: + throw new Error("unsupported alignment"); + } + availableHeight = this.rowMinimum(); + } + if (this.colWeight() === 0 && this.colMinimum() !== 0) { + switch (this.xAlignment) { + case "LEFT": + break; + case "CENTER": + xOffset += (availableWidth - this.colMinimum()) / 2; + break; + case "RIGHT": + xOffset += availableWidth - this.colMinimum(); + break; + default: + throw new Error("unsupported alignment"); + } + availableWidth = this.colMinimum(); + } this.xOffset = xOffset; this.yOffset = yOffset; this.availableWidth = availableWidth; this.availableHeight = availableHeight; this.element.attr("transform", "translate(" + this.xOffset + "," + this.yOffset + ")"); - this.hitBox.attr("width", this.availableWidth).attr("height", this.availableHeight); - this.boundingBox.attr("width", this.availableWidth).attr("height", this.availableHeight); + var boxes = [this.clipPathRect, this.hitBox, this.boundingBox]; + Utils.setWidthHeight(boxes, this.availableWidth, this.availableHeight); + } + + public registerInteraction(interaction: Interaction) { + // Interactions can be registered before or after anchoring. If registered before, they are + // pushed to this.registeredInteractions and registered during anchoring. If after, they are + // registered immediately + this.registeredInteractions.push(interaction); + if (this.element != null) { + interaction.anchor(this.hitBox); + } } public render() { @@ -70,7 +160,7 @@ class Component { chai.assert.operator(this.colWeightVal, '>=', 0, "colWeight is a reasonable number"); return this; } else { - return this.colWeightVal + return this.colWeightVal; } } diff --git a/src/interaction.ts b/src/interaction.ts index 62e8fcf0af..ffdde85ba8 100644 --- a/src/interaction.ts +++ b/src/interaction.ts @@ -1,22 +1,194 @@ /// -class DragZoomInteraction { +class Interaction { + /* A general base class for interactions. + It maintains a 'hitBox' which is where all event listeners are attached. Due to cross- + browser weirdness, the hitbox needs to be an opaque but invisible rectangle. + TODO: We should give the interaction "foreground" and "background" elements where it can + draw things, e.g. crosshairs. + */ + public hitBox: D3.Selection; + + constructor(public componentToListenTo: Component) { + } + + public anchor(hitBox: D3.Selection) { + this.hitBox = hitBox; + } + + public registerWithComponent() { + this.componentToListenTo.registerInteraction(this); + // It would be nice to have a call to this in the Interaction constructor, but + // can't do this right now because that depends on listenToHitBox being callable, which depends on the subclass + // constructor finishing first. + } +} + +interface ZoomInfo { + translate: number[]; + scale: number[]; +} + +class PanZoomInteraction extends Interaction { private zoom; - constructor(public elementToListenTo: D3.Selection, public renderers: Component[], public xScale: Scale, public yScale: Scale) { + constructor(componentToListenTo: Component, public renderers: Component[], public xScale: QuantitiveScale, public yScale: QuantitiveScale) { + super(componentToListenTo); this.zoom = d3.behavior.zoom(); - this.zoom(elementToListenTo); this.zoom.x(this.xScale.scale); this.zoom.y(this.yScale.scale); - var throttledZoom = _.throttle(() => this.rerenderZoomed(), 30); + var throttledZoom = _.throttle(() => this.rerenderZoomed(), 16); this.zoom.on("zoom", throttledZoom); + + this.registerWithComponent(); + } + + public anchor(hitBox: D3.Selection) { + super.anchor(hitBox); + this.zoom(hitBox); } private rerenderZoomed() { var translate = this.zoom.translate(); - console.log(translate); var scale = this.zoom.scale(); this.renderers.forEach((r) => { r.zoom(translate, scale); }) } } + +class AreaInteraction extends Interaction { + /* + This class is responsible for any kind of interaction in which you brush over an area + of a renderer and plan to execute some logic based on the selected area. + Right now it only works for XYRenderers, but we can make the interface more general in + the future. + You pass it a rendererComponent (:XYRenderer) and it sets up events so that you can draw + a rectangle over it. Then, you pass it callbacks that the AreaInteraction will execute on + the selected region. The first callback (areaCallback) will be passed a FullSelectionArea + object which contains info on both the pixel and data range of the selected region. + The selectionCallback will be passed a D3.Selection object that contains the elements bound + to the data in the selection region. You can use this, for example, to change their class + and display properties. + */ + private static CLASS_DRAG_BOX = "drag-box"; + private dragInitialized = false; + private dragBehavior; + private origin = [0,0]; + private location = [0,0]; + private constrainX: (n: number) => number; + private constrainY: (n: number) => number; + private dragBox: D3.Selection; + + constructor( + private rendererComponent: XYRenderer, + public areaCallback?: (a: FullSelectionArea) => any, + public selectionCallback?: (a: D3.Selection) => any, + public indicesCallback?: (a: number[]) => any + ) { + super(rendererComponent); + this.dragBehavior = d3.behavior.drag(); + this.dragBehavior.on("dragstart", () => this.dragstart()); + this.dragBehavior.on("drag", () => this.drag()); + this.dragBehavior.on("dragend", () => this.dragend()); + this.registerWithComponent(); + } + + private dragstart(){ + this.dragBox.attr("height", 0).attr("width", 0); + var availableWidth = parseFloat(this.hitBox.attr("width")); + var availableHeight = parseFloat(this.hitBox.attr("height")); + // the constraint functions ensure that the selection rectangle will not exceed the hit box + var constraintFunction = (min, max) => (x) => Math.min(Math.max(x, min), max); + this.constrainX = constraintFunction(0, availableWidth); + this.constrainY = constraintFunction(0, availableHeight); + } + + private drag(){ + if (!this.dragInitialized) { + this.origin = [d3.event.x, d3.event.y]; + this.dragInitialized = true; + } + + this.location = [this.constrainX(d3.event.x), this.constrainY(d3.event.y)]; + var width = Math.abs(this.origin[0] - this.location[0]); + var height = Math.abs(this.origin[1] - this.location[1]); + var x = Math.min(this.origin[0], this.location[0]); + var y = Math.min(this.origin[1], this.location[1]); + this.dragBox.attr("x", x).attr("y", y).attr("height", height).attr("width", width); + } + + private dragend(){ + if (!this.dragInitialized) { + return; + // It records a tap as a dragstart+dragend, but this can have unintended consequences. + // only trigger logic if we actually did some dragging. + } + this.dragInitialized = false; + var xMin = Math.min(this.origin[0], this.location[0]); + var xMax = Math.max(this.origin[0], this.location[0]); + var yMin = Math.min(this.origin[1], this.location[1]); + var yMax = Math.max(this.origin[1], this.location[1]); + var pixelArea = {xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax}; + var dataArea = this.rendererComponent.invertXYSelectionArea(pixelArea); + var fullArea = {pixel: pixelArea, data: dataArea}; + if (this.areaCallback != null) { + this.areaCallback(fullArea); + } + if (this.selectionCallback != null) { + var selection = this.rendererComponent.getSelectionFromArea(fullArea); + this.selectionCallback(selection); + } + if (this.indicesCallback != null) { + var indices = this.rendererComponent.getDataIndicesFromArea(fullArea); + this.indicesCallback(indices); + } + } + + public clearBox() { + this.dragBox.attr("height", 0).attr("width", 0); + } + + public anchor(hitBox: D3.Selection) { + super.anchor(hitBox); + var cname = AreaInteraction.CLASS_DRAG_BOX; + var element = this.componentToListenTo.element; + this.dragBox = element.append("rect").classed(cname, true).attr("x", 0).attr("y", 0); + hitBox.call(this.dragBehavior); + } +} + +class BrushZoomInteraction extends AreaInteraction { + /* + This is an extension of the AreaInteraction which is used for zooming into a selected region. + It takes the XYRenderer to initialize the AreaInteraction on, and the xScale and yScale to be + scaled according to the domain of the data selected. Note that the xScale and yScale given to + the BrushZoomInteraction can be distinct from those that the renderer depends on, e.g. if you + make a sparkline, you do not want to update the sparkline's scales, but rather the scales of a + linked chart. + */ + constructor(eventComponent: XYRenderer, public xScale: QuantitiveScale, public yScale: QuantitiveScale, public indicesCallback?: (a: number[]) => any) { + super(eventComponent); + this.areaCallback = this.zoom; + this.indicesCallback = indicesCallback; + } + + public zoom(area: FullSelectionArea) { + var originalXDomain = this.xScale.domain(); + var originalYDomain = this.yScale.domain(); + var xDomain = [area.data.xMin, area.data.xMax]; + var yDomain = [area.data.yMin, area.data.yMax]; + + var xOrigDirection = originalXDomain[0] > originalXDomain[1]; + var yOrigDirection = originalYDomain[0] > originalYDomain[1]; + var xDirection = xDomain[0] > xDomain[1]; + var yDirection = yDomain[0] > yDomain[1] + // make sure we don't change inversion of the scale by zooming + + if (xDirection != xOrigDirection) {xDomain.reverse();}; + if (yDirection != yOrigDirection) {yDomain.reverse();}; + + + this.xScale.domain(xDomain); + this.yScale.domain(yDomain); + } +} diff --git a/src/interfaces.d.ts b/src/interfaces.d.ts index b124f0b5e9..1bb35e2345 100644 --- a/src/interfaces.d.ts +++ b/src/interfaces.d.ts @@ -4,6 +4,27 @@ interface IDataset { seriesName: string; } +interface SelectionArea { + xMin: number; + xMax: number; + yMin: number; + yMax: number; +} + +interface FullSelectionArea { + pixel: SelectionArea; + data: SelectionArea; +} + + +interface IBroadcasterCallback { + (broadcaster: IBroadcaster, ...args: any[]): any; +} + +interface IBroadcaster { + registerListener: (cb: IBroadcasterCallback) => IBroadcaster; +} + // interface IRenderer extends IRendererable { // } diff --git a/src/labelComponent.ts b/src/labelComponent.ts new file mode 100644 index 0000000000..50136bf9e3 --- /dev/null +++ b/src/labelComponent.ts @@ -0,0 +1,111 @@ +/// + +class LabelComponent extends Component { + public CLASS_TEXT_LABEL = "text-label"; + + public xAlignment = "CENTER"; + public yAlignment = "CENTER"; + + private textElement: D3.Selection; + private text:string; + private textHeight = 0; + private textWidth = 0; + private isVertical = false; + private rotationAngle = 0; + private orientation = "horizontal"; + + constructor(text: string, orientation?: string) { + super(); + this.classed(this.CLASS_TEXT_LABEL, true); + this.text = text; + if (orientation === "horizontal" || orientation === "vertical-left" || orientation === "vertical-right") { + this.orientation = orientation; + } else if (orientation != null) { + throw new Error(orientation + " is not a valid orientation for LabelComponent"); + } + } + + public rowWeight(): number; + public rowWeight(newVal: number): Component; + public rowWeight(newVal?: number): any { + if (newVal != null) { + throw new Error("Row weight cannot be set on Label."); + return this; + } else { + return 0; + } + } + + public colWeight(): number; + public colWeight(newVal: number): Component; + public colWeight(newVal?: number): any { + if (newVal != null) { + throw new Error("Col weight cannot be set on Label."); + return this; + } else { + return 0; + } + } + + public rowMinimum(): number; + public rowMinimum(newVal: number): Component; + public rowMinimum(newVal?: number): any { + if (newVal != null) { + throw new Error("Row minimum cannot be directly set on Label."); + return this; + } else { + return this.textHeight; + } + } + + public colMinimum(): number; + public colMinimum(newVal: number): Component; + public colMinimum(newVal?: number): any { + if (newVal != null) { + throw new Error("Col minimum cannot be directly set on Label."); + return this; + } else { + return this.textWidth; + } + } + + public anchor(element: D3.Selection) { + super.anchor(element); + this.textElement = this.element.append("text") + .attr("alignment-baseline", "middle") + .text(this.text); + + var clientHeight = this.textElement.node().clientHeight; + var clientWidth = this.textElement.node().clientWidth; + + if (this.orientation === "horizontal") { + this.textElement.attr("transform", "translate(0 " + clientHeight/2 + ")"); + this.textHeight = clientHeight; + this.textWidth = clientWidth; + } else { + this.textWidth = clientHeight; + this.textHeight = clientWidth; + if (this.orientation === "vertical-right") { + this.textElement.attr("transform", "rotate(90) translate(0 " + (-clientHeight/2) + ")"); + } else if (this.orientation === "vertical-left") { + this.textElement.attr("transform", "rotate(-90) translate(" + (-clientWidth) + " " + clientHeight/2 + ")"); + } + } + } +} + +class TitleLabel extends LabelComponent { + public CLASS_TITLE_LABEL = "title-label"; + constructor(text: string, orientation?: string) { + super(text, orientation); + this.classed(this.CLASS_TITLE_LABEL, true); + } +} + +class AxisLabel extends LabelComponent { + public CLASS_AXIS_LABEL = "axis-label"; + constructor(text: string, orientation?: string) { + super(text, orientation); + this.classed(this.CLASS_AXIS_LABEL, true); + } +} diff --git a/src/renderer.ts b/src/renderer.ts index 2f1db14f0a..7241865d1b 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -3,16 +3,27 @@ /// class Renderer extends Component { + public CLASS_RENDERER_CONTAINER = "renderer-container"; + + public dataset: IDataset; public renderArea: D3.Selection; public element: D3.Selection; public scales: Scale[]; constructor( - public dataset: IDataset + dataset: IDataset ) { super(); super.rowWeight(1); super.colWeight(1); + + this.dataset = dataset; + this.classed(this.CLASS_RENDERER_CONTAINER, true); + } + + public data(dataset: IDataset): Renderer { + this.dataset = dataset; + return this; } public zoom(translate, scale) { @@ -21,7 +32,6 @@ class Renderer extends Component { public anchor(element: D3.Selection) { super.anchor(element); - this.element.classed("renderer-container", true); this.boundingBox.classed("renderer-bounding-box", true); this.renderArea = element.append("g").classed("render-area", true).classed(this.dataset.seriesName, true); } @@ -32,15 +42,16 @@ interface IAccessor { }; class XYRenderer extends Renderer { + public dataSelection: D3.Selection; private static defaultXAccessor = (d: any) => d.x; private static defaultYAccessor = (d: any) => d.y; - public xScale: Scale; - public yScale: Scale; + public xScale: QuantitiveScale; + public yScale: QuantitiveScale; private xAccessor: IAccessor; private yAccessor: IAccessor; - public xScaledAccessor: (datum: any) => number; - public yScaledAccessor: (datum: any) => number; - constructor(dataset: IDataset, xScale: Scale, yScale: Scale, xAccessor?: IAccessor, yAccessor?: IAccessor) { + public xScaledAccessor: IAccessor; + public yScaledAccessor: IAccessor; + constructor(dataset: IDataset, xScale: QuantitiveScale, yScale: QuantitiveScale, xAccessor?: IAccessor, yAccessor?: IAccessor) { super(dataset); this.xAccessor = (xAccessor != null) ? xAccessor : XYRenderer.defaultXAccessor; this.yAccessor = (yAccessor != null) ? yAccessor : XYRenderer.defaultYAccessor; @@ -53,6 +64,9 @@ class XYRenderer extends Renderer { this.xScale.widenDomain(xDomain); var yDomain = d3.extent(data, this.yAccessor); this.yScale.widenDomain(yDomain); + + this.xScale.registerListener(() => this.rescale()); + this.yScale.registerListener(() => this.rescale()); } public computeLayout(xOffset?: number, yOffset?: number, availableWidth?: number, availableHeight? :number) { @@ -60,13 +74,65 @@ class XYRenderer extends Renderer { this.xScale.range([0, this.availableWidth]); this.yScale.range([this.availableHeight, 0]); } + + public invertXYSelectionArea(area: SelectionArea) { + var xMin = this.xScale.invert(area.xMin); + var xMax = this.xScale.invert(area.xMax); + var yMin = this.yScale.invert(area.yMin); + var yMax = this.yScale.invert(area.yMax); + return {xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax} + } + + public getSelectionFromArea(area: FullSelectionArea) { + + var dataArea = area.data; + var inRange = (x: number, a: number, b: number) => { + return (Math.min(a,b) <= x && x <= Math.max(a,b)); + } + var filterFunction = (d: any) => { + var x = this.xAccessor(d); + var y = this.yAccessor(d); + // use inRange rather than direct comparison to avoid thinking about scale inversion + return inRange(x, dataArea.xMin, dataArea.xMax) && inRange(y, dataArea.yMin, dataArea.yMax);; + } + var selection = this.dataSelection.filter(filterFunction); + return selection; + } + + public getDataIndicesFromArea(area: FullSelectionArea) { + var dataArea = area.data; + var inRange = (x: number, a: number, b: number) => { + return (Math.min(a,b) <= x && x <= Math.max(a,b)); + } + var filterFunction = (d: any) => { + var x = this.xAccessor(d); + var y = this.yAccessor(d); + // use inRange rather than direct comparison to avoid thinking about scale inversion + return inRange(x, dataArea.xMin, dataArea.xMax) && inRange(y, dataArea.yMin, dataArea.yMax);; + } + var results = []; + this.dataset.data.forEach((d, i) => { + if (filterFunction(d)) { + results.push(i); + } + }); + return results; + } + + public rescale() { + if (this.element != null) { + this.renderArea.remove(); + this.renderArea = this.element.append("g").classed("render-area", true).classed(this.dataset.seriesName, true); + this.render(); + } + } } class LineRenderer extends XYRenderer { private line: D3.Svg.Line; - constructor(dataset: IDataset, xScale: Scale, yScale: Scale, xAccessor?: IAccessor, yAccessor?: IAccessor) { + constructor(dataset: IDataset, xScale: QuantitiveScale, yScale: QuantitiveScale, xAccessor?: IAccessor, yAccessor?: IAccessor) { super(dataset, xScale, yScale, xAccessor, yAccessor); } @@ -78,7 +144,7 @@ class LineRenderer extends XYRenderer { public render() { super.render(); this.line = d3.svg.line().interpolate("basis").x(this.xScaledAccessor).y(this.yScaledAccessor); - this.renderArea.classed("line", true) + this.dataSelection = this.renderArea.classed("line", true) .classed(this.dataset.seriesName, true) .datum(this.dataset.data); this.renderArea.attr("d", this.line); @@ -86,26 +152,77 @@ class LineRenderer extends XYRenderer { } class CircleRenderer extends XYRenderer { - private circles: D3.Selection; - - constructor(dataset: IDataset, xScale: Scale, yScale: Scale, xAccessor?: IAccessor, yAccessor?: IAccessor) { + public size: number; + constructor(dataset: IDataset, xScale: QuantitiveScale, yScale: QuantitiveScale, xAccessor?: IAccessor, yAccessor?: IAccessor, size=3) { super(dataset, xScale, yScale, xAccessor, yAccessor); + this.size = size; } public render() { super.render(); - - this.circles = this.renderArea.selectAll("circle"); - this.circles.data(this.dataset.data).enter().append("circle") + this.dataSelection = this.renderArea.selectAll("circle"); + this.dataSelection = this.dataSelection.data(this.dataset.data).enter() + .append("circle") .attr("cx", this.xScaledAccessor) .attr("cy", this.yScaledAccessor) - .attr("r", 3); + .attr("r", this.size) + .classed("selected-point", (d) => d.selected); + } +} + +class BarRenderer extends XYRenderer { + private BAR_START_PADDING_PX = 1; + private BAR_END_PADDING_PX = 1; + + private x2Accessor: IAccessor; + public x2ScaledAccessor: IAccessor; + + constructor(dataset: IDataset, + xScale: QuantitiveScale, + yScale: QuantitiveScale, + xAccessor?: IAccessor, + x2Accessor?: IAccessor, + yAccessor?: IAccessor) { + super(dataset, xScale, yScale, xAccessor, yAccessor); + + var inRange = (x: number, a: number, b: number) => { + return (Math.min(a,b) <= x && x <= Math.max(a,b)); + } + + var yDomain = this.yScale.domain(); + if (!inRange(0, yDomain[0], yDomain[1])) { + var newMin = 0; + var newMax = 1.1 * yDomain[1]; + this.yScale.widenDomain([newMin, newMax]); // TODO: make this handle reversed scales + } + + this.x2Accessor = (x2Accessor != null) ? x2Accessor : (d: any) => d.x2; + this.x2ScaledAccessor = (datum: any) => xScale.scale(this.x2Accessor(datum)); + + var x2Extent = d3.extent(dataset.data, this.x2Accessor); + this.xScale.widenDomain(x2Extent); + } + + public render() { + super.render(); + var yRange = this.yScale.range(); + var maxScaledY = Math.max(yRange[0], yRange[1]); + + var dataSelection = this.renderArea.selectAll("rect").data(this.dataset.data); + dataSelection.enter().append("rect"); + dataSelection.transition().attr("x", (d: any) => this.xScaledAccessor(d) + this.BAR_START_PADDING_PX) + .attr("y", this.yScaledAccessor) + .attr("width", (d: any) => (this.x2ScaledAccessor(d) - this.xScaledAccessor(d) + - this.BAR_START_PADDING_PX - this.BAR_END_PADDING_PX)) + .attr("height", (d: any) => { + return (maxScaledY - this.yScaledAccessor(d)); + }); + dataSelection.exit().remove(); } } // class ResizingCircleRenderer extends CircleRenderer { // public transform(translate: number[], scale: number) { -// console.log("xform"); // this.renderArea.selectAll("circle").attr("r", 0.5/scale); // } // } diff --git a/src/scale.ts b/src/scale.ts index fcb393077b..15d45592e4 100644 --- a/src/scale.ts +++ b/src/scale.ts @@ -1,7 +1,8 @@ /// -class Scale { +class Scale implements IBroadcaster { public scale: D3.Scale.Scale; + private broadcasterCallbacks: IBroadcasterCallback[] = []; constructor(scale: D3.Scale.Scale) { this.scale = scale; @@ -14,8 +15,12 @@ class Scale { public domain(): any[]; public domain(values: any[]): Scale; public domain(values?: any[]): any { - if (values != null) { - return this.scale.domain(values); + if (values != null && !(_.isEqual(values, this.scale.domain()))) { + // It is important that the scale does not update if the new domain is the same as + // the current domain, to prevent circular propogation of events + this.scale.domain(values); + this.broadcasterCallbacks.forEach((b) => b(this)); + return this; } else { return this.scale.domain(); } @@ -25,7 +30,8 @@ class Scale { public range(values: any[]): Scale; public range(values?: any[]): any { if (values != null) { - return this.scale.range(values); + this.scale.range(values); + return this; } else { return this.scale.range(); } @@ -35,17 +41,63 @@ class Scale { return new Scale(this.scale.copy()); } + public widenDomain(newDomain: number[]) { var currentDomain = this.domain(); var wideDomain = [Math.min(newDomain[0], currentDomain[0]), Math.max(newDomain[1], currentDomain[1])]; this.domain(wideDomain); + return this; + } + + public registerListener(callback: IBroadcasterCallback) { + this.broadcasterCallbacks.push(callback); + return this; + } +} + +class QuantitiveScale extends Scale { + public scale: D3.Scale.QuantitiveScale; + constructor(scale: D3.Scale.QuantitiveScale) { + super(scale); + } + + public invert(value: number) { + return this.scale.invert(value); } + public ticks(count: number) { + return this.scale.ticks(count); + } } -class LinearScale extends Scale { +class LinearScale extends QuantitiveScale { constructor() { super(d3.scale.linear()); this.domain([Infinity, -Infinity]); } } + +class ScaleDomainCoordinator { + /* This class is responsible for maintaining coordination between linked scales. + It registers event listeners for when one of its scales changes its domain. When the scale + does change its domain, it re-propogates the change to every linked scale. + */ + private currentDomain: any[] = []; + constructor(private scales: Scale[]) { + this.scales.forEach((s) => s.registerListener((sx: Scale) => this.rescale(sx))); + } + + public rescale(scale: Scale) { + var newDomain = scale.domain(); + if (_.isEqual(newDomain, this.currentDomain)) { + // Avoid forming a really funky call stack with depth proportional to number of scales + return; + } + this.currentDomain = newDomain; + // This will repropogate the change to every scale, including the scale that + // originated it. This is fine because the scale will check if the new domain is + // different from its current one and will disregard the change if they are equal. + // It would be easy to stop repropogating to the original scale if it mattered. + this.scales.forEach((s) => s.domain(newDomain)); + } +} diff --git a/src/table.ts b/src/table.ts index c16a8b608d..86e76241d4 100644 --- a/src/table.ts +++ b/src/table.ts @@ -7,10 +7,10 @@ class Table extends Component { - public rowPadding = 5; - public colPadding = 5; - public xMargin = 5; - public yMargin = 5; + public rowPadding = 0; + public colPadding = 0; + public xMargin = 0; + public yMargin = 0; private rows: Component[][]; private cols: Component[][]; @@ -106,10 +106,10 @@ class Table extends Component { component.computeLayout(childXOffset, childYOffset, colWidths[colIndex], rowHeights[rowIndex]); childXOffset += colWidths[colIndex] + this.colPadding; }); - chai.assert.operator(childXOffset - this.colPadding - this.xMargin, "<=", this.availableWidth, "final xOffset was <= availableWidth"); + chai.assert.operator(childXOffset - this.colPadding - this.xMargin, "<=", this.availableWidth + 0.1, "final xOffset was <= availableWidth"); childYOffset += rowHeights[rowIndex] + this.rowPadding; }); - chai.assert.operator(childYOffset - this.rowPadding - this.yMargin, "<=", this.availableHeight, "final yOffset was <= availableHeight"); + chai.assert.operator(childYOffset - this.rowPadding - this.yMargin, "<=", this.availableHeight + 0.1, "final yOffset was <= availableHeight"); } private static rowProportionalSpace(rows: Component[][], freeHeight: number) { diff --git a/src/utils.ts b/src/utils.ts index 561282d602..7dba96dcb6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -21,4 +21,9 @@ module Utils { export function getBBox(element: D3.Selection): SVGRect { return ( element.node()).getBBox(); } + export function setWidthHeight(elements: D3.Selection[], width: number, height: number) { + elements.forEach((e) => { + e.attr("width", width).attr("height", height); + }) + } } diff --git a/style.css b/style.css index 85329cac36..772376b46b 100644 --- a/style.css +++ b/style.css @@ -7,6 +7,23 @@ div { display: inline; } +.text-label text { + font-family: TrebuchetMS; +} + +.axis-label text { + font-size: 18pt; + font-style: italic; +} + +.title-label text { + font-size: 36pt; +} + +.text-label-vertical { + writing-mode: tb; +} + .axis path, .axis line { fill: none; @@ -30,6 +47,9 @@ div { circle { fill: steelblue; } +rect { + fill: steelblue; +} .table-rect { fill: none; @@ -59,6 +79,7 @@ circle { .bounding-box { fill: none; + stroke: black; } .renderer-bounding-box { @@ -72,3 +93,14 @@ circle { .table-bounding-box { stroke: blue; } + +.drag-box { + fill: aliceblue; + opacity: 1; + +} + +.selected-point { + fill: orange; + stroke: none; +} diff --git a/test/axisTests.ts b/test/axisTests.ts index d4701073a3..8d404b370a 100644 --- a/test/axisTests.ts +++ b/test/axisTests.ts @@ -6,9 +6,3 @@ /// var assert = chai.assert; - -describe("truthiness", () => { - it("true", () => { - assert.isTrue(true, "true is true!"); - }) -}) diff --git a/test/componentTests.ts b/test/componentTests.ts new file mode 100644 index 0000000000..517f447cc3 --- /dev/null +++ b/test/componentTests.ts @@ -0,0 +1,51 @@ +/// +/// +/// +/// + +/// +/// +/// +/// +/// + +var assert = chai.assert; + +function assertComponentXY(component: Component, x: number, y: number, message: string) { + // use to examine the private variables + var xOffset = ( component).xOffset; + var yOffset = ( component).yOffset; + assert.equal(xOffset, x, message); + assert.equal(yOffset, y, message); +} + +describe("Component behavior", () => { + it("fixed-width component will align to the right spot", () => { + var svg = generateSVG("300", "300"); + var component = new Component(); + component.rowMinimum(100).colMinimum(100); + component.anchor(svg); + component.computeLayout(); + assertComponentXY(component, 0, 0, "top-left component aligns correctly"); + + component.xAlignment = "CENTER"; + component.yAlignment = "CENTER"; + component.computeLayout(); + assertComponentXY(component, 100, 100, "center component aligns correctly"); + + component.xAlignment = "RIGHT"; + component.yAlignment = "BOTTOM"; + component.computeLayout(); + assertComponentXY(component, 200, 200, "bottom-right component aligns correctly"); + svg.remove(); + }) + it("component defaults are as expected", () => { + var c = new Component(); + assert.equal(c.rowMinimum(), 0, "rowMinimum defaults to 0"); + assert.equal(c.rowWeight() , 0, "rowWeight defaults to 0"); + assert.equal(c.colMinimum(), 0, "colMinimum defaults to 0"); + assert.equal(c.colWeight() , 0, "colWeight defaults to 0"); + assert.equal(c.xAlignment, "LEFT", "xAlignment defaults to LEFT"); + assert.equal(c.yAlignment, "TOP" , "yAlignment defaults to TOP"); + }) +}) diff --git a/test/tableTests.ts b/test/tableTests.ts index c2fc094c4f..d5d94e4984 100644 --- a/test/tableTests.ts +++ b/test/tableTests.ts @@ -7,13 +7,10 @@ /// /// /// +/// var assert = chai.assert; -function generateSVG(width, height) { - return d3.select("body").append("svg:svg").attr("width", width).attr("height", height); -} - function generateBasicTable(nRows, nCols) { // makes a table with exactly nRows * nCols children in a regular grid, with each // child being a basic Renderer (todo: maybe change to basic component) @@ -142,6 +139,5 @@ describe("Table layout", () => { assertBBoxEquivalence(bboxes[5], [50, 340], "right axis bbox"); assertBBoxEquivalence(bboxes[4], [300, 340], "plot bbox"); svg.remove(); - }) - + }) }) diff --git a/test/testUtils.ts b/test/testUtils.ts new file mode 100644 index 0000000000..2480827771 --- /dev/null +++ b/test/testUtils.ts @@ -0,0 +1,3 @@ +function generateSVG(width, height) { + return d3.select("body").append("svg:svg").attr("width", width).attr("height", height); +}