charts/smallmultiples.mjs (614 lines of code) (raw):
import helpers from "./shared/helpers"
import dataTools from "./shared/dataTools"
import Tooltip from "./shared/tooltip"
import ColorScale from "./shared/colorscale"
import { numberFormat, mustache, mobileCheck, validate, validateString, contains, stackMin, stackMax, timeCheck } from './shared/toolbelt';
import Dropdown from "./shared/dropdown";
import { addDrops } from "./shared/drops";
import { drawShowMore } from "./shared/showmore";
export default class Smallmultiples {
constructor(settings) {
this.settings = settings
console.log("settings",settings)
this.init()
}
init() {
drawShowMore(this.settings.enableShowMore)
if (this.settings.tooltip != "") {
this.tooltip = new Tooltip(this.settings.tooltip)
}
if (this.settings.dropdown.length > 0) {
addDrops()
this.dropdown = new Dropdown(
"dataPicker",
dataTools.getDropdown(this.settings.dropdown, this.settings.keys)
)
this.dropdown.on("dropdown-change", (label) => {
let data = this.settings.dropdown.find(d => d.label == label)
this.settings.smallmultiples = [ this.settings.xColumn, this.settings.groupBy, ...data.values.split(',').map(d => d.trim()) ]
if (this.settings.tooltip != "") {
//this.tooltip.updateTemplate(data.tooltip)
}
this.render()
})
}
d3.select("#switch").on("click", () => {
this.settings.scaleBy = (this.settings.scaleBy == "group") ? "chart" : "group"
this.render()
})
this.render()
}
render() {
let { modules,
height,
width,
isMobile,
colors,
datum,
title,
subtitle,
source,
dateFormat,
xAxisLabel,
yAxisLabel,
timeInterval,
tooltip,
periodDateFormat,
marginleft,
margintop,
marginbottom,
marginright,
xAxisDateFormat,
groupBy,
xColumn,
keys,
data,
labels,
trendline,
userkey,
periods,
type,
numCols,
breaks,
chartType,
scaleBy,
parseTime,
enableShowMore,
aria,
smallmultiples,
colorScheme,
dropdown,
xScale,
yScale,
xFormat } = this.settings
const $tooltip = (this.tooltip) ? this.tooltip : false
// console.log(`xAxisDateFormat: ${xAxisDateFormat}`)
// console.log("xFormat", xFormat)
datum = data.map(d => Object.keys(d).filter((key) => contains(key, smallmultiples)).reduce((cur, key) => { return Object.assign(cur, { [key]: d[key] })}, {}))
const chartKey = d3.select("#chartKey")
isMobile = mobileCheck()
let label = (scaleBy == "group")
? "Show max scale for group"
: "Show max scale for each chart"
d3.select("#switch").html(label)
let hideNullValues = (chartType === "bar") ? "no" : "yes"
let dataKeys = JSON.parse(JSON.stringify(smallmultiples));
// console.log(dataKeys)
// User has not set groupBy, so guess second column
if (groupBy == '') {
// console.log("yeah")
groupBy = dataKeys[1];
}
if (parseTime) {
xFormat.date = true
}
dataKeys.splice(dataKeys.indexOf(xColumn), 1)
dataKeys.splice(dataKeys.indexOf(groupBy), 1)
const multiples = [...new Set(datum.map((d) => d[groupBy]))]
console.log("multiples",multiples)
const windowWidth = Math.max(
document.documentElement.clientWidth,
window.innerWidth || 0
)
datum.forEach(function(d) {
if (parseTime) {
console.log("yeh")
d[xColumn] = parseTime(d[xColumn])
}
if (chartType === "bar") {
d.Total = d3.sum(dataKeys, (k) => +d[k])
}
})
// console.log("datum", datum)
/*
if (periods.length > 0) {
periods.forEach((d) => {
if (typeof d.start == "string") {
if (this.parseTime != null) {
d.start = this.parseTime(d.start)
if (d.end != "") {
d.end = this.parseTime(d.end)
d.middle = new Date((d.start.getTime() + d.end.getTime()) / 2)
} else {
d.middle = d.start
}
} else {
d.start = +d.start
if (d.end != "") {
d.end = +d.end
d.middle = (d.end + d.start) / 2
} else {
d.middle = d.start
}
}
}
})
}
*/
colors = new ColorScale()
const keyColor = dataTools.getKeysColors({
keys: dataKeys,
userKey: userkey,
option: { colorScheme : colorScheme }
})
//console.log("keyColor",keyColor)
colors.set(keyColor.keys, keyColor.colors)
let showGroupMax = (scaleBy == "group") ? true : false ;
const containerWidth = document
.querySelector("#graphicContainer")
.getBoundingClientRect().width
const containerHeight = containerWidth
let noColsSet = false
if (numCols === null) {
if (containerWidth <= 500) {
numCols = 1
} else if (containerWidth <= 750) {
numCols = 2
} else {
numCols = 3
}
} else {
noColsSet = true
if (numCols > 3) {
if (isMobile) {
numCols = 2
}
}
}
width = containerWidth / numCols
if (height != null) {
height = height
} else {
height = 200
if (isMobile) {
height = 150
}
}
width = width - marginleft - marginright
console.log(`Height 1: ${height}`)
height = height - margintop - marginbottom
console.log(`Height 2: ${height}`)
d3.select("#graphicContainer")
.selectAll(".chart-grid")
.remove()
multiples.forEach((key, index) => {
drawChart({
data: datum,
key,
chartType: chartType,
isMobile: isMobile,
hasTooltip: (tooltip != ""),
index
})
})
chartKey.html("")
if (dataKeys.length > 1) {
dataKeys.forEach((key) => {
const keyDiv = chartKey.append("div").attr("class", "keyDiv")
keyDiv.append("span").attr("class", "keyCircle").style("background-color", () => colors.get(key))
keyDiv.append("span").attr("class", "keyText").text(key)
})
}
function sanitizeCSSIdentifier(identifier) {
// Remove any characters that are not letters, digits, underscores, or hyphens.
let sanitized = identifier.replace(/[^a-zA-Z0-9_-]/g, '');
// CSS identifiers cannot start with a digit.
if (/^\d/.test(sanitized)) {
sanitized = '_' + sanitized;
}
return sanitized;
}
function drawChart({
data,
key,
chartType,
isMobile,
hasTooltip,
index
}) {
console.log("key", key)
const id = sanitizeCSSIdentifier(dataTools.getId(key)),
chartId = `#${id}`,
isBar = chartType === "bar",
isLine = chartType === "line",
isArea = chartType === "area"
d3.select("#graphicContainer")
.append("div")
.attr("id", id)
.attr("class", "chart-grid")
.style("width", function(d) {
if (numCols === 1) {
return "100%"
} else if (numCols === 2) {
return "49.9%"
} else if (numCols === 3) {
return "32.9%"
} else if (numCols > 3) {
var smWidth = (100 / numCols - 0.1).toString() + "%"
return smWidth
}
})
d3.select(chartId)
.append("div")
.text(key)
.attr("class", "chartSubTitle")
.style("padding-left", marginleft + "px")
const svg = d3.select(chartId)
.append("svg")
.attr("width", width + marginleft + marginright)
.attr("height", height + margintop + marginbottom)
.style("overflow", "visible")
const features = svg.append("g")
.attr("transform", "translate(" + marginleft + "," + margintop + ")")
// console.log("scales", xScale, yScale)
var x = d3[xScale]()
.range([0, width])
//.padding(0)
if (parseTime) {
if (!isBar) {
x = d3.scaleTime()
.range([0, width])
}
} else {
if (!isBar) {
x = d3.scaleLinear()
.range([0, width])
}
}
const y = d3[yScale]()
.range([height,0])
const duration = 1000
let yMax = showGroupMax ? datum : datum.filter((item) => item[groupBy] === key)
if (xScale == 'scaleBand') {
x.domain(datum.map(d => d[xColumn]))
} else {
x.domain(d3.extent(datum.map(d => d[xColumn])))
}
const tickMod = Math.round(x.domain().length / 3)
let ticks = width / 200
console.log("ticks",ticks)
console.log("yehhhhhhhh", xFormat.date)
var xAxis = d3.axisBottom(x)
.tickValues(ticks)
if (isBar) {
if (x.domain().length > 9 && numCols > 2) {
ticks = [ x.domain()[3] , x.domain()[x.domain().length - 4]]
}
if (xFormat.date) {
console.log("blah")
xAxis.tickValues(ticks).tickFormat(d3.timeFormat("%b %Y"))
}
}
if (numCols > 3 && !isBar && xFormat.date) {
var blahTicks = [x.domain()[0],
new Date((x.domain()[0].getTime() + x.domain()[1].getTime())/2),
x.domain()[1]
]
xAxis = d3
.axisBottom(x)
.tickValues(blahTicks)
.tickFormat(d3.timeFormat("%-d"))
}
if (numCols == 1 && !isBar && xFormat.date) {
xAxis = d3.axisBottom(x)
.ticks(6)
}
const yAxis = d3.axisLeft(y)
.tickFormat((d) => numberFormat(d))
.ticks(3)
features
.append("g")
.attr("class", "x")
.attr("transform", "translate(0," + height + ")")
.call(xAxis)
features
.append("g")
.attr("class", "y")
const update = () => {
yMax = showGroupMax ? datum : datum.filter((item) => item[groupBy] === key)
var allValues = []
dataKeys.forEach(key => {
allValues = allValues.concat(d3.extent(yMax, (d) => d[key]))
})
if (chartType === "bar" && scaleBy == "group") {
y.domain([Math.floor(0), d3.max(datum.map(d => d.Total))]) // d3.min(datum.map(d => d.Total))
} else {
y.domain(d3.extent(allValues))
}
const chartData = data.filter((d) => d[groupBy] === key)
const drawOptions = {
features,
data: chartData,
duration,
x,
y,
hasTooltip,
index
}
if (isBar) {
drawBarChart(drawOptions)
} else if (isLine) {
drawLineChart(drawOptions)
} else if (isArea) {
drawAreaChart(drawOptions)
}
if (hasTooltip && !isBar) {
//drawHoverFeature(drawOptions)
}
if (periods.length > 0) {
// drawPeriods(drawOptions)
}
features.select(".y").transition().duration(duration).call(yAxis)
}
update()
}
function drawBarChart({ features, data, duration, x, y, hasTooltip}) {
let xRange = timeCheck(timeInterval, datum, xColumn)
x.domain(xRange)
let layers = d3.stack()
.offset(d3.stackOffsetDiverging)
.keys(dataKeys)(data)
layers.forEach(function(layer) {
layer.forEach(function(subLayer) {
subLayer.group = layer.key
subLayer.groupValue = subLayer.data[layer.key]
subLayer.total = subLayer.data.Total
})
})
if (scaleBy != "group") {
y.domain([d3.min(layers, stackMin), d3.max(layers, stackMax)])
}
var bars = features.selectAll(".bar")
.data(layers, (d) => d.key)
.enter()
.append("g")
.attr("class", 'bar') // (d) => "layer " + d.key
.style("fill", (d, i) => colors.get(d.key))
bars
.selectAll("rect")
.data((d) => d)
.enter()
.append("rect")
.attr("class", "bar")
.attr("height", 0)
.attr("y", height)
.merge(bars)
.transition()
.duration(duration)
.attr("x", (d) => x(d.data[xColumn]))
.attr("y", (d) => y(d[1]))
.attr("class", "barPart")
.attr("title", (d) => d.data[d.key])
.attr("data-group", (d) => d.group)
.attr("data-count", (d) => d.data[d.key])
.attr("height", (d) => y(d[0]) - y(d[1]))
.attr("width", (d) => {
return (x.bandwidth() > 4) ? x.bandwidth() - 2 :
(x.bandwidth() > 2) ? x.bandwidth() - 1 : 0
})
bars
.exit()
.transition()
.duration(duration)
.attr("height", 0)
.attr("y", height)
.remove()
if ($tooltip) {
$tooltip.bindEvents(
d3.selectAll(".bar"),
containerWidth,
containerHeight
)
}
}
function drawLineChart({ features, data, duration, x, y }) {
features.selectAll(`.line-path`).remove()
// console.log("data", data)
smallmultiples.forEach((key, i) => {
const line = d3
.line()
.x((d) => x(d[xColumn]))
.y((d) => y(d[key]))
if (hideNullValues === "yes") {
line.defined(function (d) {
return !isNaN(d[key])
})
}
const initialLine = d3
.line()
.x((d) => x(d[xColumn]))
.y(height)
if (hideNullValues === "yes") {
initialLine.defined(function (d) {
return !isNaN(d[key])
})
}
features
.append("path")
.datum(data)
.attr("class", `${key} line-path`)
.attr("fill", "transparent")
.attr("d", initialLine)
.transition()
.duration(duration)
.attr("d", line)
.attr("stroke-width", 2)
.attr("stroke", (d) => colors.get(key))
if ($tooltip) {
$tooltip.bindEvents(
d3.selectAll(".line-path"),
containerWidth,
containerHeight
)
}
})
}
function drawAreaChart({
features,
data,
duration,
x,
y
}) {
const $area = features.selectAll(".area-path").data([data])
dataKeys.forEach((key, i) => {
const area = d3.area()
.x((d) => x(d[xColumn]))
.y0((d) => y(0))
.y1((d) => y(d[key]))
const initialArea = d3.area()
.x((d) => x(d[xColumn]))
.y0((d) => y(0))
.y1((d) => height)
$area
.enter()
.append("path")
.attr("class", "area-path")
.attr("fill", colors.get(key))
.attr("d", initialArea)
.merge($area)
.transition()
.duration(duration)
.attr("d", area)
$area
.exit()
.transition()
.duration(duration)
.attr("d", initialArea)
.remove()
})
}
/*
function drawPeriods({ features, data, x, index }) {
features.selectAll(".periodLine").remove()
features.selectAll(".periodLabel").remove()
features
.selectAll(".periodLine .start")
.data(periods)
.enter()
.append("line")
.attr("x1", (d) => x(d.start))
.attr("y1", 0)
.attr("x2", (d) => x(d.start))
.attr("y2", height)
.attr("class", "periodLine mobHide start")
.attr("stroke", "#bdbdbd")
.attr("stroke-dasharray", "2,2")
.attr("opacity", (d) => (d.start < x.domain()[0]) ? 0 : 1)
.attr("stroke-width", 1)
features
.selectAll(".periodLine .end")
.data(periods.filter(b => b.end != ""))
.enter()
.append("line")
.attr("x1", (d) => x(d.end))
.attr("y1", 0)
.attr("x2", (d) => x(d.end))
.attr("y2", height)
.attr("class", "periodLine mobHide")
.attr("stroke", "#bdbdbd")
.attr("stroke-dasharray", "2,2")
.attr("opacity", (d) => (d.end > x.domain()[1]) ? 0 : 1)
.attr("stroke-width", 1)
if (index === 0) {
features
.selectAll(".periodLabel")
.data(periods)
.enter()
.append("text")
.attr("x", (d) => {
if (d.labelPosition) {
if (d.labelPosition == "start") {
return x(d.start) + 5
} else {
return x(d.middle)
}
} else {
return x(d.middle)
}
})
.attr("y", -5)
.attr("text-anchor", (d) => (d.textAnchor) ? d.textAnchor : "middle")
.attr("class", "periodLabel mobHide")
.attr("opacity", 1)
.text((d) => d.label)
}
}
function drawHoverFeature({ features, data, x }) {
const xColumn = xColumn
const $hoverLine = features
.append("line")
.attr("x1", 0)
.attr("y1", 0)
.attr("x2", 0)
.attr("y2", height)
.style("opacity", 0)
.style("stroke", "#333")
.style("stroke-dasharray", 4)
const $hoverLayerRect = features
.append("rect")
.attr("width", width)
.attr("height", height)
.style("opacity", 0)
// Find the data based on mouse position
const getTooltipData = (d, event) => {
const bisectDate = d3.bisector((d) => d[xColumn]).left,
x0 = x.invert(d3.mouse(event)[0]),
i = bisectDate(data, x0, 1),
d0 = data[i - 1],
d1 = data[i]
let tooltipData = {}
if (d0 && d1) {
tooltipData = x0 - d0[xColumn] > d1[xColumn] - x0 ? d1 : d0
} else {
tooltipData = d0
}
return tooltipData
}
// Render tooltip data
const templateRender = (d, event) => {
const data = getTooltipData(d, event)
return mustache(template, {
...helpers,
...data
})
}
$hoverLayerRect
.on("mousemove touchmove", function (d) {
const x0 = x.invert(d3.mouse(this)[0])
const tooltipText = templateRender(d, this)
tooltip.show(
tooltipText,
width,
height + margin.top + margin.bottom
)
$hoverLine.attr("x1", x(x0)).attr("x2", x(x0)).style("opacity", 0.5)
})
.on("mouseout touchend", function () {
tooltip.hide()
$hoverLine.style("opacity", 0)
})
}
*/
}
}