### **Problem**
Setting opacity when using webgl isn't getting applied 'properl…y'. (ex: a value of `[0.42, 0.8, 0.62, 0.1]` isn't 1/10th the opacity).
### **Interim Solution**
I do offer an interim solution for those encountering this though. It seems that when setting the opacity, ALL rgb values need to be divided by the same denominator that the opacity is (or multiplied by the same decimal percentage). So instead of `[1, 1, 1, 0.5]`, the new color would be `[0.5, 0.5, 0.5, 0.5]`.
### **Code Summary**
The lengthy code below is a research project I have been doing into utilizing WebGL (D3FC in this case) in data visualization. I understand its a lot of code. You can run the code simply by saving to an html file and opening that html file in a browser (I am using chrome). I have highlighted a few test cases to see some of the odd behavior (search for **OPACITY ISSUE TESTING** to see the 2 areas to consider).
### **Conclusion**
Thankfully I was able to discover the interim solution which works but I think updating the library to have the opacity value operate like a normal rgba would be a welcomed patch. Love the library and thanks to all who have worked on it.
```
<html>
<head>
<title>72</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3@5.15.0/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3fc@14.2.3/build/d3fc.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-svg-annotation@2.5.1/indexRollup.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"rel="stylesheet"/>
<style>
body {
background-color: rgb(11, 49, 66);
color: white;
margin: 0;
display: flex;
flex-direction: column;
height: 100%;
font-family: sans-serif;
/* font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; */
}
#legend {
display: inline-block;
position: absolute;
/* margin-left: 10px; */
margin-top: 20px;
}
.legend-item {
display: flex;
align-items: center;
margin-right: 15px;
cursor: pointer;
font-size: 11px;
margin-bottom: 10px;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 5px;
}
.legend-text-inactive {
color: grey;
text-decoration: line-through;
}
#title {
/* flex-direction: row;
display: flex; */
align-items: center;
}
#title .subtitle {
margin: 5px 0 0 20px;
}
#title .subtitle h2 {
display: inline-block;
margin: 0;
}
.btn-container {
display: inline-block;
margin: 0px 12px 0px 0px;
background-color: #092937;
padding: 4px 5px;
border-radius: 3px;
}
.float-right {
float: right;
}
.select-btn {
display: inline-block;
width: 20px;
height: 20px;
padding-top: 0px;
padding-right: 2px;
padding-bottom: 2px;
padding-left: 2px;
font-size: 16px;
background-color: transparent;
border: none;
color: #989898;
border-radius: 5px;
cursor: pointer;
}
.select-btn:hover {
color: white;
}
.select-btn i {
font-size: 12px;
}
.select-btn.active {
pointer-events: none;
color: white;
}
.select-btn.disabled {
pointer-events: none;
color: rgb(85, 89, 95);
}
.btn-section-container {
float: left;
border-right: 2px solid #98989852;
margin-right: 5px;
}
.btn-section-container button:last-child {
margin-right: 4px;
}
.center-icon {
margin-top: 2px;
}
#chart-container {
flex: 1;
position: relative;
display: inline-block;
}
#chart-container > #chart {
position: absolute;
bottom: 0;
right: 0;
/* top: 0; */
/* left: 0; */
}
#chart {
/* margin: auto; */
}
.move-cursor svg {
cursor: move !important;
user-select: none;
}
.pointer-cursor {
cursor: pointer !important;
}
#chart-group {
margin-top: -10px;
}
.tick text {
/* display: none; */
fill: rgba(255, 255, 255, 0.75);
}
.tick path {
stroke: rgba(255, 255, 255, 0.3);
}
path {
stroke: rgba(255, 255, 255, 0.3);
}
.gridline-y {
stroke: #ffffff19;
}
.gridline-x {
stroke: #ffffff19;
}
d3fc-group {
transform: rotate(90deg);
}
d3fc-canvas {
background-color: #00000029;
border-top-left-radius: 5px;
}
.canvas-plot-area {
opacity: .15;
}
d3fc-svg g.brush rect:nth-of-type(1) {
/* url will not be available on hs */
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cline x1='12' y1='0' x2='12' y2='24' stroke='%23FFFFFF' stroke-width='2'/%3E%3Cline x1='0' y1='12' x2='24' y2='12' stroke='%23FFFFFF' stroke-width='2'/%3E%3C/svg%3E") 12 12, crosshair;
}
.x-axis-label.month-year {
transform: rotate(-90deg) translate(calc(-50% + 2px), 15px);
}
.x-axis-label.month-day-year {
transform: rotate(-90deg) translate(calc(-50% - 6px), 15px);
}
.x-axis-label.month-day-year-time {
transform: rotate(-90deg) translate(calc(-50% - 19px), 15px);
}
.y-axis-label {
transform: rotate(-90deg) translate(-18px, -3px);
}
.d3fc-tooltip {
opacity: 0;
transition: opacity .4s;
position: absolute;
pointer-events: none;
padding: 6px 9px;
border-radius: 4px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}
.d3fc-tooltip .tooltip-title {
margin: 0px 0px 3px 0px;
font-weight: 600;
font-size: 18px;
}
.d3fc-tooltip.show {
opacity: 1 !important;
transition: opacity 0s;
}
.tooltip-trace {
margin-left: 7px;
opacity: .5;
}
.tooltip-datetime {
font-style: italic;
color: #e4e4e4;
font-size: 12px;
}
.td-date {
color: #0000006c;
font-weight: 600;
}
.td-time {
color: #00000037;
font-weight: 600;
}
.td-separator {
margin: 0px 4px;
}
</style>
</head>
<body>
<div id="title">
<div class="subtitle">
<h2>d3fc</h2>
<div class="btn-container float-right">
<button class="select-btn center-icon" id="selectCursor"><i class="fa-solid fa-arrow-pointer"></i></button>
<button class="select-btn active" id="selectBrush"><i class="fa-solid fa-magnifying-glass"></i></button>
<button class="select-btn" id="selectPan"><i class="fa-solid fa-arrows-up-down-left-right"></i></button>
<button class="select-btn" id="selectZoomIn"><i class="fa-solid fa-plus"></i></button>
<button class="select-btn" id="selectZoomOut"><i class="fa-solid fa-minus"></i></button>
<button class="select-btn" id="selectHome"><i class="fa-solid fa-house"></i></button>
<div class="btn-section-container">
<button class="select-btn center-icon" id="selectPlayPause"><i class="fa-solid fa-pause"></i></button>
</div>
<div class="btn-section-container">
<button class="select-btn center-icon" id="selectRunPlayback"><i class="fa-solid fa-location-dot"></i></button>
<button class="select-btn center-icon" id="selectToggleChart"><i class="fa-solid fa-up-right-and-down-left-from-center"></i></button>
</div>
</div>
</div>
</div>
<div id="chart-group">
<div id="chart-container">
<div id="chart"></div>
</div>
<div id="legend"></div>
</div>
<script>
// GLOBALS
// set container & chart dimensions
const chartContainerElement = document.querySelector('#chart-container');
const unrotatedChartElement = document.querySelector('#chart'); // the oringal un-rotated element
let d3fcCanvasElement; // the rotated canvas element
let d3fcSvgElement; // the rotated svg element
let height = 500;
let width = 1000;
const xBuffer = 5;
const yBuffer = 0;
chartContainerElement.style.maxHeight = `${height + yBuffer}px`;
chartContainerElement.style.height = `${height + yBuffer}px`;
chartContainerElement.style.width = `${width + xBuffer}px`;
unrotatedChartElement.style.width = `${height}px`;
unrotatedChartElement.style.height = `${width}px`;
unrotatedChartElement.style.top = `${-Math.floor(height / 2)}px`;
unrotatedChartElement.style.left = `${Math.floor(height / 2)}px`;
let firstLoad = true;
let traces;
let allSelectors;
let minStartTime;
let maxEndTime;
let timeAxisFormat = d3.utcFormat("%b %Y");
let timeAxisClassArray = [{ class: 'month-year', selected: true }, { class: 'month-day-year', selected: false }, { class: 'month-day-year-time', selected: false }];
const states = ['Coincident', 'Co-Traveling', 'Encounter', 'Verified'];
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// OPACITY ISSUE TESTING - here you can set different test cases for the opacity
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// const colors = [[.8, .42, .42, 1], [.42, .8, .62, 1], [.35, .6, .85, 1], [.33, .46, .56, 1]]; // original colors
// const colors = [[.8, .42, .42, 1], [.42, .8, .62, .1], [.35, .6, .85, .1], [.33, .46, .56, .1]]; // original colors with desired opacity - the values with .1 are not 1 tenth the opacity
// const colors = [[0, 0, 0, .1], [0, 0, 0, .3], [0, 0, 0, .7], [0, 0, 0, .1]]; // TEST - opacity seems to work just fine here for some reason
// const colors = [[0, 0, 0, 1], [0, 0, 0, .1], [.5, .5, .5, .1], [1, 1, 1, .1]]; // TEST - opacity does not seem to be applied properly here
const colors = [[.1, .1, .1, .1], [.4, .4, .4, .4], [.7, .7, .7, .7], [1, 1, 1, 1]]; // TEST - properly applies opacity for the 'white' color - for example [.1, .1, .1, .1] isn't 'near black'
// const opacityMultiplier = .17
// const colors = [
// [.8, .42, .42, 1], // for reference of full opacity
// [.42, .8, .62, 1].map((c) => c * opacityMultiplier),
// [.35, .6, .85, 1].map((c) => c * opacityMultiplier),
// [.33, .46, .56, 1].map((c) => c * opacityMultiplier)
// ]; // TEST - seems in order to do opacity, you need to have each rgb color be multiplied by the opacity
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// OPACITY ISSUE TESTING - END PT 1
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
let visible = [true, true, true, true]; // these fields should be combined
mode = 'brush';
// DATA
let data = [];
let visualizedData = [];
const numberOfSelectors = 10;
const numberOfPills = 100 * numberOfSelectors;
let actualTotal = 0;
// utils
const numberedData = (length) => {
return Array.from({length}, (v, i) => i).map((x) => x.toString());
}
const generateData = (selectorTotal, pillsTotal) => {
const localData = [];
let averagePillsPerSelector = Math.floor(pillsTotal / selectorTotal) < 1 ? 1 : Math.floor(pillsTotal / selectorTotal);
allSelectors = numberedData(selectorTotal);
for (let i = 0; i < selectorTotal; i++) {
let previousEndDate = new Date().getTime() - (Math.random() * 1000 * 60 * 60 * 24 * 365); // day max range
const randomAveragePills = Math.floor((Math.random() * (averagePillsPerSelector / 2)) + (averagePillsPerSelector * 3 / 4));
actualTotal += randomAveragePills;
for (let j = 0; j < randomAveragePills; j++) {
// for (let j = 0; j < averagePillsPerSelector; j++) {
const trace = Math.floor(Math.random() * (colors.length - .0001));
const selector = i;
const start = previousEndDate;
const end = Math.floor(previousEndDate + (Math.random() * 1000 * 60 * 60 * 24 * 10)); // day max range
localData.push(
{
selector,
start,
end,
traceIndex: trace,
}
); // push time range pill to the proper trace
minStartTime = minStartTime < start ? minStartTime : start;
maxEndTime = maxEndTime > end ? maxEndTime : end;
previousEndDate = Math.floor(end + (Math.random() * 1000 * 60 * 60 * 24 * (Math.random() < .25 ? 20 : 2))); // 25% chance of 10 days, 75% chance of 2 days
}
}
return localData;
}
data = generateData(numberOfSelectors, numberOfPills); // 50 selectors, 100 pills, 2 pills per selector
visualizedData = data;
console.log(`${numberOfSelectors.toLocaleString()} selectors | ${actualTotal.toLocaleString()} pills`);
// console.log(`${numberOfSelectors.toLocaleString()} selectors | ${numberOfPills.toLocaleString()} pills`);
///////
// SVG SETUP
let yScale = d3.scaleLinear().domain([-.5, allSelectors.length - .5]);
let xScale = d3.scaleLinear().domain([minStartTime, maxEndTime]);
let yScaleOriginal = yScale.copy();
let xScaleOriginal = xScale.copy();
let barSize = (y_scale) => {
const output = y_scale.range()[1] * Math.pow(y_scale.domain()[1] - y_scale.domain()[0], -1) * .85;
return output < 1 ? 1 : output;
};
///////
// LEGEND
const legendContainer = d3.select("#legend")
.append("div")
const DELAY = 300; // Adjust this value (in milliseconds) to suit your needs
let timeout;
const legendItems = legendContainer.selectAll(".legend-item")
.data(states)
.join("div")
.attr("class", "legend-item")
.on("click", function(state, index, elements) {
if (d3.event.detail === 1) { // Check if it's a single click
clearTimeout(timeout);
timeout = setTimeout(() => {
// toggle
const isActive = !d3.select(this).classed("inactive");
d3.select(this).classed("inactive", isActive);
d3.select(this).select("span")
.classed("legend-text-inactive", isActive);
// update viz
visible[index] = !visible.find((v, i) => i === index);
createWebglChart();
redraw();
}, DELAY);
}
})
.on("dblclick", function (state, index, elements) {
clearTimeout(timeout); // Prevent the single click action
// solo
const isActive = !d3.select(this).classed("inactive");
const children = Array.from(this.parentElement.children);
if (isActive && children.filter(child => child !== this).every(child => child.classList.contains("inactive"))) { // set all as active
children.forEach(child => {
child.classList.remove("inactive");
d3.select(child).select("span")
.classed("legend-text-inactive", false);
});
// // update viz
visible = visible.map(v => true);
} else {
children.forEach((child, i) => {
if (child.innerText === state) { // this is the one that is active
child.classList.remove("inactive");
d3.select(child).select("span")
.classed("legend-text-inactive", false);
// update viz
visible[i] = true;
} else { // the rest to be inactive
child.classList.add("inactive");
d3.select(child).select("span")
.classed("legend-text-inactive", true);
// update viz
visible[i] = false;
}
});
}
createWebglChart();
redraw();
});
legendItems.append("div")
.attr("class", "legend-color")
// .style("fill", d => colors[d]);
// .style("background-color", d => colors[d]);
.style("background", (d, i) => d === 'Verified' ? `repeating-linear-gradient(
-45deg,
rgba(${colors[i].map((c, i) => i < 3 ? c * 255 : c).join(",")}),
rgba(${colors[i].map((c, i) => i < 3 ? c * 255 : c).join(",")}) 3px,
transparent 3px,
transparent 7px
)` :
`rgba(${colors[i].map((c, i) => i < 3 ? c * 255 : c).join(",")})`);
legendItems.append("span")
.text(d => d);
// UTILS
const dateFormat = (diff) => {
if (diff < 72 * 60 * 60 * 1000) { // less than 72 hours
timeAxisClassArray.forEach(t => t.class === 'month-day-year-time' ? t.selected = true : t.selected = false);
timeAxisFormat = d3.utcFormat("%b %d %Y %H:%M");
} else if (diff < 365 * 24 * 60 * 60 * 1000) { // less than a year
timeAxisClassArray.forEach(t => t.class === 'month-day-year' ? t.selected = true : t.selected = false);
timeAxisFormat = d3.utcFormat("%b %d %Y");
} else {
timeAxisClassArray.forEach(t => t.class === 'month-year' ? t.selected = true : t.selected = false);
timeAxisFormat = d3.utcFormat("%b %Y");
}
}
// ACTION BUTTONS
d3.select("#selectPlayPause").on("click", function() {
if (d3.select(this.firstChild).classed("fa-pause")) {
d3.select(this.firstChild).classed("fa-pause", false);
d3.select(this.firstChild).classed("fa-play", true);
// pause playback
paused = true;
} else {
d3.select(this.firstChild).classed("fa-pause", true);
d3.select(this.firstChild).classed("fa-play", false);
// play playback
paused = false;
}
});
d3.select("#selectRunPlayback").on("click", function() {
console.log('selected', selected);
console.log('time range', xScale.domain());
});
d3.select("#selectToggleChart").on("click", function() {
if (scrollView) {
// reenact pan & zoom buttons
d3.selectAll(".select-btn").classed("disabled", false);
d3.select(this.firstChild).classed("fa-down-left-and-up-right-to-center", false);
d3.select(this.firstChild).classed("fa-up-right-and-down-left-from-center", true);
// show all y
scrollView = false;
// hide y table
if (viewableSelectors.length > 26) {
d3.select("#y-table").classed("y-table-hidden", true);
yGrid.selectAll('.tick').classed("y-table-hidden", true);
} else {
d3.select("#y-table").classed("y-table-hidden", false);
yGrid.selectAll('.tick').classed("y-table-hidden", false);
}
// update svg characteristics
updateToggledY(maxHeight);
} else {
// disable pan & zoom buttons
d3.select("#selectBrush").classed("disabled", true);
d3.select("#selectPan").classed("disabled", true);
d3.select("#selectZoomIn").classed("disabled", true);
d3.select("#selectZoomOut").classed("disabled", true);
d3.select("#selectHome").classed("disabled", true);
d3.select(this.firstChild).classed("fa-down-left-and-up-right-to-center", true);
d3.select(this.firstChild).classed("fa-up-right-and-down-left-from-center", false);
// scroll for y axis
scrollView = true;
// selectCursor
disablePan();
disableBrush();
d3.selectAll(".select-btn").classed("active", false);
d3.select("#selectCursor").classed("active", true);
currentTimeLine.classed("time-line-unselectable", false);
// show y table
d3.select("#y-table").classed("y-table-hidden", false);
yGrid.selectAll('.tick').classed("y-table-hidden", false);
// update svg characteristics
updateToggledY(viewableSelectors.length * rowHeight);
}
});
d3.select("#selectCursor").on("click", function() {
mode = 'cursor';
d3.select('g.brush').style("display", "none");
d3.selectAll(".select-btn").classed("active", false);
d3.select(this).classed("active", true);
d3fcSvgElement.classed('move-cursor', false);
// currentTimeLine.classed("time-line-unselectable", false);
});
d3.select("#selectBrush").on("click", function() {
mode = 'brush';
d3.select('g.brush').style("display", null);
d3.selectAll(".select-btn").classed("active", false);
d3.select(this).classed("active", true);
d3fcSvgElement.classed('move-cursor', true);
// currentTimeLine.classed("time-line-unselectable", true);
});
d3.select("#selectPan").on("click", function() {
mode = 'pan';
d3.select('g.brush').style("display", "none");
d3.selectAll(".select-btn").classed("active", false);
d3.select(this).classed("active", true);
d3fcSvgElement.classed('move-cursor', true);
// currentTimeLine.classed("time-line-unselectable", true);
});
d3.select("#selectZoomIn").on("click", function() {
const zoomScale = 4; // zoom in by 50%
const heightDivider = height / zoomScale;
const widthDivider = width / zoomScale;
const xRangeStart = xScale.range()[1];
const yRangeStart = yScale.range()[0];
let yCalcS;
let yCalcE;
if (yScale.domain()[1] - yScale.domain()[0] < 4) {
yCalcS = Math.round((yScale.domain()[1] + yScale.domain()[0]) / 2);
yCalcE = Math.round((yScale.domain()[1] + yScale.domain()[0]) / 2);
} else if (yScale.domain()[1] - yScale.domain()[0] === 4) {
yCalcS = yScale.domain()[0] + 1.5;
yCalcE = yScale.domain()[1] - 1.5;
} else {
yCalcS = Math.round(yScale.invert(heightDivider + yRangeStart)),
yCalcE = Math.round(yScale.invert(heightDivider * (zoomScale - 1) + yRangeStart))
}
// const yS = yCalcS < 0 ? 0 : yCalcS;
// const yE = yCalcE > allSelectors.length - 1 ? allSelectors.length - 1 : yCalcE;
zoomChart(
xScale.invert(widthDivider * (zoomScale - 1) + xRangeStart),
xScale.invert(widthDivider + xRangeStart),
yCalcS,
yCalcE
);
});
d3.select("#selectZoomOut").on("click", function() {
const zoomScale = 2; // zoom out by 100% - which matches a previous zoom in
const heightDivider = height / zoomScale;
const widthDivider = width / zoomScale;
const xRangeStart = xScale.range()[1];
const yRangeStart = yScale.range()[0];
// don't zoom beyond the data
const xCalcS = xScale.invert(width + widthDivider + xRangeStart);
const xCalcE = xScale.invert(0 - widthDivider + xRangeStart);
// const xS = xCalcS < minStartTime ? minStartTime : xCalcS;
// const xE = xCalcE > maxEndTime ? maxEndTime : xCalcE;
let yCalcS;
let yCalcE;
if (yScale.domain()[1] - yScale.domain()[0] === 0) {
yCalcS = Math.round(yScale.domain()[0]);
yCalcE = Math.round(yScale.domain()[0]);
} else if (yScale.domain()[1] - yScale.domain()[0] === 1) {
yCalcS = yScale.domain()[0] - .5;
yCalcE = yScale.domain()[1] + .5;
} else {
yCalcS = Math.round(yScale.invert(0 - heightDivider + yRangeStart) + .501);
yCalcE = Math.round(yScale.invert(height + heightDivider + yRangeStart) - .501);
}
// const yS = yCalcS < 0 ? 0 : yCalcS;
// const yE = yCalcE > allSelectors.length - 1 ? allSelectors.length - 1 : yCalcE;
zoomChart(xCalcS, xCalcE, yCalcS, yCalcE);
// zoomChart(xS, xE, yS, yE); // cannot zoom out beyond the data
});
// handles zooming out y axis new values - protecting against zooming out beyond the data
function getLargerSubset(subset, master) {
let outputLength = 0;
if (subset.length === 1) {
outputLength = 3;
} else if (subset.length % 2 === 0) {
outputLength = subset.length * 2;
} else {
outputLength = subset.length * 2 - 1;
}
const expandSideBy = (outputLength - subset.length) / 2;
const newLeftIndex = master.indexOf(subset[0]) - expandSideBy;
const newrightIndex = master.indexOf(subset[subset.length - 1]) + expandSideBy;
const validLeftIndex = newLeftIndex >= 0 ? newLeftIndex : 0;
const validRightIndex = newrightIndex <= master.length -1 ? newrightIndex : master.length - 1;
return master.slice(validLeftIndex, validRightIndex + 1);
}
// Button event: toggle brush mode
d3.select("#selectHome").on("click", function() {
zoomChart(minStartTime, maxEndTime, 0, allSelectors.length - 1);
});
// ANNOTATIONS
// Tooltip div (add to body once)
const tooltip = d3.select("body")
.append("div")
.attr("class", "d3fc-tooltip")
const getDataPoint = (coord, xPad, yPad) => {
const x = xScale.invert(xScale.range()[0] - coord.x + xPad); // why is the range reversed here
const yInitial = yScale.invert(coord.y - yPad);
const yDecimal = Math.abs(yInitial % 1);
const y = yDecimal > .425 && yDecimal < .575 ? -1 : Math.round(yInitial);
const dataPoint = data.find(d => d.start < x && x < d.end && y === d.selector);
return dataPoint;
}
let tooltipData;
const pointer = fc.pointer().on("point", ([coord]) => {
if (mode === 'cursor') {
// does this get computationally expensive if you run a playback?
const xPad = 47; // is there any way to calculate these?
const yPad = 15;
if (coord && (coord.x >= xPad && coord.x - xPad <= xScale.range()[0]) && (coord.y >= yPad && coord.y - yPad <= yScale.range()[1])) {
const dataPoint = getDataPoint(coord, xPad, yPad);
if (dataPoint === undefined || !visible[dataPoint.traceIndex]) {
tooltip.classed('show', false);
d3fcSvgElement.classed('pointer-cursor', false);
return;
}
// update tooltip
const dateFormatter = d3.utcFormat('%b %d %Y');
const timeFormatter = d3.utcFormat('%H:%M');
tooltip
.style("background-color", `rgba(${colors[dataPoint.traceIndex].map((c, i) => i < 3 ? c * 255 : c).join(",")})`)
.html(
`<div class='tooltip-title'><span class='tooltip-id'>${dataPoint.selector}<span><span class='tooltip-trace'>${states[dataPoint.traceIndex]}</span></div>
<div class='tooltip-datetime'>
<span class='td-date'>${dateFormatter(dataPoint.start)}</span>
<span class='td-time'>${timeFormatter(dataPoint.start)}</span>
<span class='td-separator'>-</span>
<span class='td-date'>${dateFormatter(dataPoint.end)}</span>
<span class='td-time'>${timeFormatter(dataPoint.end)}</span>
</div>`
);
tooltip.classed('show', true);
d3fcSvgElement.classed('pointer-cursor', true);
tooltip
.style("left", (d3.event.pageX + 15) + "px")
.style("top", (d3.event.pageY + 20) + "px");
} else {
tooltip.classed('show', false);
d3fcSvgElement.classed('pointer-cursor', false);
}
}
});
///////
// ZOOM SVG
const pan = d3
.zoom()
// .scaleExtent([1, 100]) // how far do you want to allow users to zoom
.on("zoom", (data, notsure, d3fc_element) => {
if (!d3.event.sourceEvent?.wheelDelta && mode === 'pan') {
tooltip.classed('show', false);
yScale.domain(yScale.range().map((d) => yScale.invert(d - d3.event.sourceEvent.movementY)));
yScale.range(yScale.range().map((d) => d - d3.event.sourceEvent.movementY));
xScale.domain(xScale.range().map((d) => xScale.invert(d + d3.event.sourceEvent.movementX)));
xScale.range(xScale.range().map((d) => d + d3.event.sourceEvent.movementX));
redraw();
}
})
.on('end', (one, two, three) => {
// if panning stops and is only showing a sliver of a row, show the whole row.
zoomChart(xScale.domain()[0], xScale.domain()[1], Math.round(yScale.domain()[0] + .078), Math.round(yScale.domain()[1] - .078));
});
// WEBGL IS TOUGH TO 'UPDATE' (EX: BANDWIDTH)
// INSTEAD IT NEEDS TO REBUILD THE WHOLE CHART (AS WITH ZOOMING).
// WHILE PANNING WORKS FLAWLESSLY THOUGH AT 40 FPS (1M PILLS),
// REBUILDING TAKES ABOUT 1/3 OF A SECOND (1M PILLS)
const zoomChart = (xS, xE, yS, yE, duration = 750) => {
// yS and yE are expected to be rounded
yScale.domain([yS - .5, yE + .5]);
xScale.domain([xS, xE]);
dateFormat(xScale.domain()[1] - xScale.domain()[0]); // update date format
createWebglChart();
redraw();
}
///////
// CREATE WEBGL CHART
let chart;
let timebarValue = new Date();
const createWebglChart = () => {
// console.log(fc.randomGeometricBrownianMotion().steps(1e4)(1))
// GRIDLINES - might be perfomantly taxing
const gridline = fc.annotationCanvasGridline()
.xScale(xScale)
.yScale(yScale)
.xTicks(26)
.yTicks(4)
// TIMELINE
const webglTimebarLine = fc
.seriesWebglLine()
.lineWidth(4)
.crossValue(d => d.y) // Your time scale's domain (e.g., a Date object)
.mainValue(d => {
// console.log(d);
return d.x
}) // The y-value (e.g., 0 to 1 for the bottom/top of the chart)
// .defined(() => true)
// .equals(previousData => previousData.length > 0); // Only draw the line if there is data
.decorate(program => {
// fc.webglFillColor([1, 1, 1, 0.2])(program); // Apply a constant color to the line
fc.webglStrokeColor().value([.7, .7, .7, .1]).data(d => d)(program); // Apply a constant color to the line
// WebGL styling is done via attributes/uniforms passed to shaders
// This is more complex than Canvas 2D
// const lineColor = [1, 1, 1, 1]; // RGBA (blue, opaque)
// program.vertexShader().appendHeader(`
// attribute vec4 aColor;
// varying vec4 vColor;
// `);
// program.fragmentShader().appendHeader(`
// varying vec4 vColor;
// `);
// program.fragmentShader().appendBody(`
// gl_FragColor = vColor;
// `);
// Create an attribute for color (or a uniform if all lines are same color)
// fc.webglConstant([[1, 1, 1, 1]])(program); // Apply a constant color to the line
// fc.webglStrokeColor().value([1, 1, 1, .5]).data(data)(program); // Apply a constant color to the line
// For dashed lines, it's significantly more involved in WebGL.
// You'd typically need to implement custom shaders that calculate
// fragment positions and apply a dash pattern based on those.
// D3FC's seriesWebglLine itself doesn't have a built-in .setLineDash()
// like Canvas 2D.
});
// BRUSH
let idleTimeout;
const idleDelay = 350;
const brush = fc.brush().on('end', (e, one, two) => {
if (mode === 'brush') {
if (e.selection) {
const xDomain = e.yDomain; // swap x and y bc of rotated chart
const yDomain = e.xDomain;
zoomChart(xDomain[0], xDomain[1], Math.round(yDomain[0] + .1), Math.round(yDomain[1] - .1)); // TODO add Math.round() to the y axis
}
else {
if (!idleTimeout) {
// detect double clicks
idleTimeout = setTimeout(() => (idleTimeout = null), idleDelay);
} else {
zoomChart(minStartTime, maxEndTime, 0, allSelectors.length - 1); // home
}
}
}
});
// D3FC WEBGL SERIES
const barSeries =
fc
.seriesWebglBar()
.equals((a, b) => a === b) // key for performance BUT you then are stuck to a single bandwidth
.defined(() => true) // not crucial but implements a buffer?
.crossValue(d => d.selector)
.mainValue(d => d.end)
.baseValue(d => d.start)
.bandwidth(d => barSize(yScale))
.decorate(program => {
// fc.webglFillColor([0, 0, 0, 0])(program); // Apply a constant color to the line
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// OPACITY ISSUE TESTING - this is where the colors are applied
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
fc.webglFillColor().value(d => visible[d.traceIndex] ? colors[d.traceIndex] : [0, 0, 0, 0]).data(data)(program); // Weird color issues were due to an old (but still very new) chrome version
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// OPACITY ISSUE TESTING - END PT 2
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
})
// https://d3fc.io/examples/earth-and-mars/
// gradient but not repeating
// const areaSeries = fc
// .seriesWebglArea()
// .mainValue(mainValue)
// .crossValue(crossValue)
// .defined(() => true)
// .equals(d => d.length)
// .decorate(program => {
// program.vertexShader().appendHeader(`varying lowp vec4 vColor;`)
// .appendBody(`float colourModifier = smoothstep(50.0, 400.0, aMainValue);
// vColor = (vec4(0.55, 0.65, 0.75, 1) * colourModifier) + ((1.0 - colourModifier) * vec4(0.75, 0.45, 0.45, 1));
// float verticalFade = max(0.0, smoothstep(-1.1, -0.9, gl_Position.y) - 0.15);
// vColor.a = vColor.a * verticalFade;
// `);
// program.fragmentShader().appendHeader(`
// varying lowp vec4 vColor;
// `).appendBody(`
// gl_FragColor = vColor;
// `);
// });
// Timeline - doesn't have to be rotated as it is a vertical bar
// CHART
chart =
fc
.chartCartesian(yScale, xScale) // creates the d3fc canvas & the axes | seriesSvgGrouped / seriesSvgBar | also is reversed so as to rotate
.canvasPlotArea(gridline)
.webglPlotArea( // only render the point series on the WebGL layer
fc
.seriesWebglMulti()
.series([barSeries, webglTimebarLine])
.mapping((d, index, series) => {
switch (series[index]) {
case barSeries:
return d.data;
case webglTimebarLine:
// console.log(timebarValue);
return [
{ x: timebarValue.getTime(), y: yScale.domain()[0] - 1 }, // Start point of line
{ x: timebarValue.getTime(), y: yScale.domain()[1] + 1 } // End point of line
];
}
})
)
.svgPlotArea(
// only render the annotations series on the SVG layer | also necessary for zooming
fc
.seriesSvgMulti().series([brush]).mapping(d => null)
// .series([tooltipSeries])
// .mapping(d => d.data)
// .mapping(d => d.data.filter(point => visible[point.traceIndex])) // Filter data for tooltip based on visibility
)
.decorate(sel => {
// Using the enter selection to ensure this only runs once
const svgPlotArea = sel.enter().select('.svg-plot-area').node();
const canvasPlotArea = sel.enter().select('.canvas-plot-area').node();
const webglPlotArea = sel.enter().select('.webgl-plot-area').node();
// The order in `selectAll` determines the z-index (last one is on top)
// this is so the gridlines are behind the bars
d3.selectAll([canvasPlotArea, webglPlotArea, svgPlotArea])
.order(); // This method reorders the DOM elements based on the selection order
sel
.on("draw", () => { // handles zooming for webgl - its based on the svg zooming
// NEEDED FOR PAN & ZOOM
if (firstLoad) {
width = d3.select('canvas').node().height;
height = d3.select('canvas').node().width;
yScaleOriginal.range([0, height]);
xScaleOriginal.range([0, width]);
d3fcCanvasElement = d3.select('d3fc-canvas'); // the rotated element
d3fcSvgElement = d3.select('d3fc-svg'); // the rotated element
d3.select('d3fc-group').on('click', (e) => {
if (mode === 'cursor') {
const coord = d3.pointer(event, d3fcSvgElement.node());
const dataPoint = getDataPoint({ x: coord[0], y: coord[1] }, -1, -1); // no padding necessary bc the onclick event is solely on the svg
if (dataPoint) {
zoomChart(dataPoint.start, dataPoint.end, dataPoint.selector, dataPoint.selector);
}
}
});
firstLoad = false;
}
})
.call((data, i, d3fc) => pan(data, i, d3fc)) // zooming for svg
.call(pointer)
// .call((e) => console.log('e', e))
})
.xTicks(26)
.yTicks(4)
.yTickFormat(timeAxisFormat)
.yTickFormat(timeAxisFormat)
.xDecorate(sel => {
sel.select('text').classed('y-axis-label', true);
})
.yDecorate(sel => {
sel.select('text').classed('x-axis-label', true);
timeAxisClassArray.forEach(t => sel.select('text').classed(t.class, t.selected));
})
}
// UPDATE TIMEBAR// To update the timebar:
// function updateTimebar() {
// timebarValue = new Date(); // Update the timebar's value
// createWebglChart();
// redraw(); // Redraw the chart
// }
// // Set up an interval
// setInterval(updateTimebar, 5000);
// REDRAW
const redraw = () => {
d3.select("#chart")
.datum({ data })
// .transition() // because the webgl layer needs to get remade every time a zoom occurs, transitions are not possible
// .duration(500)
.call(chart);
};
///////
// RUN CODE
createWebglChart();
redraw();
///////
</script>
</body>
</html>
```