d3-parliament-chart
Version:
parliament / congress / legislature charts simplified
368 lines (309 loc) • 12.8 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.d3 = global.d3 || {}));
}(this, (function (exports) { 'use strict';
/**
* Generate the points and angle for a single section of the parliament
*
* @param seats - number of seats in this section
* @param startRad - start radians of this section
* @param endRad - end radians of this section
* @param seatRadius - radius of the seats
* @param rowHeight - height of the row
* @param graphicHeight - height of the graphic
* @param sectionGap - The gap between sections
* @returns {[]}
*/
const generatePartial = ({
seats, startRad, endRad, seatRadius, rowHeight, graphicHeight, sectionGap,
}) => {
// Calculate the radius of the graph, because we don't want the poitns to extend
// beyond the width of the graphic
const graphRadius = graphicHeight - seatRadius;
// Final Array
let points = [];
// Which row we are currently drawing
let currentRow = 0;
// Create all the points
while (points.length < seats) {
// The radius of the row we are currently drawing
const currentRowRadius = graphRadius - (rowHeight * currentRow);
// We need to also justify for the gap of the section, which radians value varies per row
const currentRowGapRad = Math.atan((seatRadius + (sectionGap / 2)) / currentRowRadius);
const currentRowStartRad = startRad + currentRowGapRad;
const currentRowEndRad = endRad - currentRowGapRad;
// If our data doesn't fit inside the row or the graph, we just stop
if (currentRowEndRad <= currentRowStartRad || currentRowRadius <= 0) break;
// Find the minimum size step given the radius
const currRadStep = Math.atan((2.5 * (seatRadius)) / currentRowRadius);
// Find how many seats are in this row
const rowSeats = Math.min(
Math.floor((currentRowEndRad - currentRowStartRad) / currRadStep),
seats - points.length - 1,
);
// Get adjusted step size so that things are justified evenly
// edge case if there is only one seat in this row
const roundedRadStep = rowSeats
? (currentRowEndRad - currentRowStartRad) / rowSeats
: 0;
// Add all the seats in this row
for (let currSeat = 0; currSeat <= rowSeats; currSeat += 1) {
// Get the current angle of the seat we are drawing
const currentAngle = rowSeats
? (currSeat * roundedRadStep + currentRowStartRad)
// edge case if there is only one seat in this row, we put it in the middle
: ((currentRowStartRad + currentRowEndRad) / 2);
// convert the angle to x y coordinates
points = points.concat([{
x: Math.cos(currentAngle)
* (graphRadius - (rowHeight * currentRow))
+ graphicHeight,
// flip the y coordinates
y: graphicHeight - (Math.sin(currentAngle)
* (graphRadius - (rowHeight * currentRow))
+ seatRadius)
// Add seatRadius and any sectionGap / 4 so that we vertically center
+ seatRadius + (sectionGap / 4),
angle: currentAngle,
}]);
}
currentRow += 1;
}
return points;
};
/**
* Generates the list of all points and their x and y positions
*
* @param totalPoints - total number of points we want to draw
* @param sections - total number of sections we want to draw
* @param sectionGap - gap between sections
* @param seatRadius - radius of each seat
* @param rowHeight - height of each row
* @param graphicWidth - width of the entire graphic
* @returns {this}
*/
var getParliamentPoints = (totalPoints, { sections, sectionGap, seatRadius, rowHeight }, graphicWidth) => {
// Calculate the graphic height
const graphicHeight = graphicWidth / 2;
// Get the number of final sections
const finalSections = Math.min(totalPoints, sections);
// Angle step per section in radians
const radStep = Math.PI / finalSections;
// Divide the seats evenly among the sections, while also calculating
// the start radians and end radians.
const sectionObjs = Array(finalSections)
// First evenly divide the seats into each section
.fill({ seats: Math.floor(totalPoints / finalSections) })
// add the start and end radians
.map((a, i) => ({ ...a, startRad: i * radStep, endRad: (i + 1) * radStep }));
// There are leftover seats that we need to fit into sections
// Calculate how many there are
let leftoverSeats = totalPoints % finalSections;
// If leftover seats is 0, we can skip this entire section
if (leftoverSeats !== 0) {
// We want to add the leftover seats to the center section first, then move outward
// We do this by separating the sections into two arrays, left and right
const right = Array(finalSections).fill(null).map((c, i) => i);
const left = right.splice(0, Math.floor(finalSections / 2)).reverse();
// Add the seats
while (leftoverSeats > 0) {
// Whichever array is longer, we pop from that array and add to that section first
if (left.length >= right.length) sectionObjs[left.shift()].seats += 1;
else sectionObjs[right.shift()].seats += 1;
// decrement leftoverSeats by one
leftoverSeats -= 1;
}
}
// Call the section partial generation tool for each section
return sectionObjs.map((s) => generatePartial({
...s, seatRadius, rowHeight, graphicHeight, sectionGap,
}))
// flatten the array
.reduce((acc, val) => [...acc, ...val], [])
// sort by angle
.sort((a, b) => b.angle - a.angle)
// remove angle from returned dataset
.map((r) => {
const { angle, ...rest } = r;
return rest;
});
};
const debugColor = '#1abc9c';
/**
* Draw Debugging circle and section guides.
*/
var debugGuides = (selection, graphicWidth, options, totalSeats) => {
const sections = Math.min(totalSeats, options.sections);
// Create a container for our debug lines
const g = selection.append('g')
.attr('class', 'debug')
.attr('transform', `translate(0, ${options.sectionGap / 4})`);
// The radius of the semicircle
const radius = graphicWidth / 2;
// Semicircle frame
g.append('path')
.attr('d', `M${graphicWidth},${radius} a1,1 0 0,0 -${graphicWidth},0 m 0 -${options.sectionGap / 2} l ${graphicWidth} 0`)
.attr('stroke', debugColor)
.attr('stroke-width', 2)
.attr('stroke-dasharray', '5 5')
.attr('fill', 'none');
// Section borders
const radStep = Math.PI / sections;
for (let i = 1; i < sections; i += 1) {
const radAngle = radStep * i;
// If the section gap is 0 we only need to draw one line
if (options.sectionGap <= 0) {
g.append('line')
.attr('x1', radius).attr('y1', radius)
.attr('x2', (Math.cos(radAngle) * radius) + radius)
.attr('y2', radius - (Math.sin(radAngle) * radius))
.attr('stroke', debugColor)
.attr('stroke-width', 2)
.attr('stroke-dasharray', '5 5');
} else {
// Calculate the offset and draw two lines.
const offsetX = Math.sin(radAngle) * (options.sectionGap / 2);
const offsetY = Math.cos(radAngle) * (options.sectionGap / 2);
g.append('line')
.attr('x1', radius - offsetX)
.attr('y1', radius - offsetY)
.attr('x2', ((Math.cos(radAngle) * radius) + radius) - offsetX)
.attr('y2', (radius - (Math.sin(radAngle) * radius)) - offsetY)
.attr('stroke', debugColor)
.attr('stroke-width', 2)
.attr('stroke-dasharray', '5 5');
g.append('line')
.attr('x1', radius + offsetX)
.attr('y1', radius + offsetY)
.attr('x2', ((Math.cos(radAngle) * radius) + radius) + offsetX)
.attr('y2', (radius - (Math.sin(radAngle) * radius)) + offsetY)
.attr('stroke', debugColor)
.attr('stroke-width', 2)
.attr('stroke-dasharray', '5 5');
}
}
};
/**
* ___ ____ ___ _ _ _ ___ _ _
* | \__ / | _ \__ _ _ _| (_)__ _ _ __ ___ _ _| |_ / __| |_ __ _ _ _| |_
* | |) |_ \ | _/ _` | '_| | / _` | ' \/ -_) ' \ _| | (__| ' \/ _` | '_| _|
* |___/___/ |_| \__,_|_| |_|_\__,_|_|_|_\___|_||_\__| \___|_||_\__,_|_| \__|
*
* A d3 plugin for making semi-circle parliament charts.
*/
var parliamentChart = (data = [], width = 0) => {
// Dimensions of the graphic
let graphicWidth = parseFloat(width);
// clean out any x and y values provided in data objects.
let rawData = data.map(({ x, y, ...restProps }) => restProps);
// visual options
const options = {
sections: 4, // Number of sections to divide the half circle into
sectionGap: 60, // The gap of the aisle between sections
seatRadius: 12, // The radius of each seat
rowHeight: 42, // The height of each row
};
// Whether we should draw the debug lines or not
let debug = false;
// //////////////////////////////////////////////////////////////////////////
// Selection call
//
// This function gets called on instances such as:
// d3.select('g').call(parliamentChart())
const parliamentChart = (selection) => {
if (graphicWidth === 0) {
// Sets the graphicWidth based on our selected container
graphicWidth = selection.node().getBoundingClientRect().width;
}
// Get the processed data (filter for entries that have x and y locations)
const processedData = parliamentChart.data().filter((r) => r.x && r.y);
// Remove existing chart
selection.select('g.parliament-chart').remove();
// Add new chart
const innerSelection = selection
.append('g')
.attr('class', 'parliament-chart');
// First remove any existing debug lines
innerSelection.select('g.debug').remove();
// Append debug lines
if (debug) debugGuides(innerSelection, graphicWidth, options, processedData.length);
return innerSelection
.selectAll('circle')
.data(processedData)
.enter()
.insert('circle')
.attr('cx', (d) => d.x)
.attr('cy', (d) => d.y)
.attr('r', options.seatRadius)
.attr('fill', (d) => d.color || '#AAA');
};
// //////////////////////////////////////////////////////////////////////////
// Getters and Setters
// Sets the width and the height of the graphic
parliamentChart.width = (w) => {
// eslint-disable-next-line no-restricted-globals
if (!isNaN(w)) {
// parse the width
graphicWidth = parseFloat(w);
}
return parliamentChart;
};
// Create getters and setters for sections, sectionGap, seatRadius, and rowHeight
Object.keys(options)
.forEach((attr) => {
parliamentChart[attr] = (s) => {
// eslint-disable-next-line no-restricted-globals
if (!isNaN(s)) {
options[attr] = parseInt(s, 10);
return parliamentChart;
}
return options[attr];
};
});
// enable / disable debug mode
parliamentChart.debug = (b) => {
debug = !!b;
return parliamentChart;
};
// //////////////////////////////////////////////////////////////////////////
// Data Processing
//
// Gets the data processed data with x and y coordinates or sets
// the raw data.
parliamentChart.data = (d) => {
// If an argument with new data is provided
if (d) {
// clean out any x and y values provided in data objects.
rawData = d.map(({ x, y, ...restProps }) => restProps);
return parliamentChart;
}
// If width is not set, don't calculate this instance
if (graphicWidth <= 0 || rawData.length <= 0) return rawData;
// Check if we have already run this instance
if (rawData.every((r) => r.x && r.y)) return rawData;
// The number of points we need to fit
const totalPoints = rawData.length;
// The locations of all the points
const locations = getParliamentPoints(totalPoints, options, graphicWidth);
// Add locations to the rawData object
locations.forEach((coords, i) => rawData[i] = ({ ...rawData[i], ...coords }));
// return the data
return rawData;
};
// Instead of passing in an array of every single point, we pass in an array of objects
// that each have a key `seats` that specifies the number of seats. This function can only
// set, not get.
parliamentChart.aggregatedData = (d) => {
rawData = d.reduce((acc, val) => {
const { seats = 0, x, y, ...restProps } = val;
return [...acc, ...Array(seats).fill(restProps)];
}, []);
return parliamentChart;
};
return parliamentChart;
};
exports.getParliamentPoints = getParliamentPoints;
exports.parliamentChart = parliamentChart;
Object.defineProperty(exports, '__esModule', { value: true });
})));