I’ve been working on custom functions to provide pseudo logarithmic scales for a d3 line chart. In my implementation you pass a split point then the lower half is log and the upper half linear. This is useful for visualising frequency response graphs.
I’ve been struggling to implement zoom functionality. It zooms at the minute but doesn’t pan as the visual range of each half needs adjusting based on the new location of the split point. This isn’t a necessity, but would be nice if anyone has the time to look at it.
This is the code I’ve got so far:
// Create Pseudo Logarithmic Scale
// -------------------------------
function pseudoLogScale(splitPoint = 1000) {
let domain = [20, 20000]; // Default domain with split at 1000
let range = [0, 100]; // Default range
// Core scale function that handles value conversion based on domain and range
function scale(value) {
const log = d3.scaleLog()
.domain([domain[0], splitPoint])
.range([range[0], (range[0] + range[1]) / 2]);
const linear = d3.scaleLinear()
.domain([splitPoint, domain[1]])
.range([(range[0] + range[1]) / 2, range[1]]);
return value <= splitPoint ? log(value) : linear(value);
}
// Method to set/get domain
scale.domain = function(_) {
if (!arguments.length) return domain;
domain = _;
return scale;
};
// Method to set/get range
scale.range = function(_) {
if (!arguments.length) return range;
range = _;
return scale;
};
scale.rescaleX = function(transform) {
// Always use the original domain for the transformation
const originalLogDomain = [20, splitPoint]; // Assuming 20 is the lower bound of the original domain
const originalLinearDomain = [splitPoint, 20000]; // Assuming 20000 is the upper bound of the original domain
const log = d3.scaleLog()
.domain(originalLogDomain)
.range([range[0], (range[0] + range[1]) / 2]);
const linear = d3.scaleLinear()
.domain(originalLinearDomain)
.range([(range[0] + range[1]) / 2, range[1]]);
// Apply the transform to both sections (log and linear)
const newLogDomain = transform.rescaleX(log).domain();
const newLinearDomain = transform.rescaleX(linear).domain();
console.log("Original log domain:", originalLogDomain);
console.log("Transformed log domain:", newLogDomain);
console.log("Original linear domain:", originalLinearDomain);
console.log("Transformed linear domain:", newLinearDomain);
// Ensure the splitPoint remains constant and clamp the values based on the transformed original domain
const clampedLogMin = Math.max(newLogDomain[0], originalLogDomain[0]);
const clampedLogMax = Math.min(newLogDomain[1], splitPoint);
const clampedLinearMin = Math.max(newLinearDomain[0], splitPoint);
const clampedLinearMax = Math.min(newLinearDomain[1], originalLinearDomain[1]);
console.log("Clamped log domain min:", clampedLogMin);
console.log("Clamped log domain max:", clampedLogMax);
console.log("Clamped linear domain min:", clampedLinearMin);
console.log("Clamped linear domain max:", clampedLinearMax);
// Update the domain with the new values
domain = [clampedLogMin, clampedLinearMax];
console.log("Updated domain after clamping:", domain);
// Return the updated scale
return scale.domain([domain[0], domain[1]]);
};
// Generate grid lines for both log and linear sections
scale.gridLines = function(transform = null) {
const domainMin = domain[0];
const domainMax = domain[1];
if (transform) {
// Log the current transform details
console.log("Transform details:", transform);
console.log("Scale factor (k):", transform.k);
console.log("Translate X:", transform.x);
console.log("Translate Y:", transform.y);
// Use custom rescaleX to update the domain
console.log("Before custom rescaleX, domain:", domain);
scale.rescaleX(transform); // This updates the domain
console.log("After custom rescaleX, updated domain:", domain);
}
if (transform) {
// Log the domain before applying the transformation
console.log("Before custom rescaleX, domain:", domain);
// Use custom rescaleX to update the domain
scale.rescaleX(transform);
// Log the updated domain after the transformation
console.log("After custom rescaleX, updated domain:", domain);
}
// Generate grid lines for the log section
const logGridLines = [];
for (let base = 10; base < splitPoint; base *= 10) {
for (let i = 1; i <= 10; i++) {
const logValue = base * i;
if (logValue >= domain[0] && logValue <= splitPoint) {
logGridLines.push(logValue);
}
}
}
console.log("Generated log grid lines:", logGridLines);
// Generate grid lines for the linear section
const linearGridLines = d3.scaleLinear()
.domain([splitPoint, domain[1]])
.ticks(4);
console.log("Generated linear grid lines:", linearGridLines);
// Combine log and linear grid lines
const combinedGridLines = Array.from(new Set(logGridLines.concat(linearGridLines)));
console.log("Combined grid lines:", combinedGridLines);
return combinedGridLines;
};
// Generate only preferred ticks for the log and linear sections
scale.ticks = function(count) {
const domainMin = domain[0];
const domainMax = domain[1];
let ticks = [domainMin];
// Add preferred log ticks
const preferredLogTicks = [100, 200, 500, 1000].filter(d => d > domainMin);
ticks = ticks.concat(preferredLogTicks);
// Generate linear ticks
const linearTicks = d3.scaleLinear()
.domain([splitPoint, domainMax])
.ticks(count - preferredLogTicks.length - 1);
ticks = ticks.concat(linearTicks);
ticks = Array.from(new Set(ticks)).sort((a, b) => a - b);
return ticks;
};
// Method to format ticks
scale.tickFormat = function() {
return function(d) {
if (d <= splitPoint) {
return kFormatter(parseFloat(d.toFixed()));
}
return kFormatter(d);
};
};
// Invert method
scale.invert = function(value) {
const log = d3.scaleLog()
.domain([domain[0], splitPoint])
.range([range[0], (range[0] + range[1]) / 2]);
const linear = d3.scaleLinear()
.domain([splitPoint, domain[1]])
.range([(range[0] + range[1]) / 2, range[1]]);
return value <= (range[0] + range[1]) / 2 ? log.invert(value) : linear.invert(value);
};
// Method to create a copy of the scale
scale.copy = function() {
return pseudoLogScale(splitPoint).domain(domain).range(range);
};
return scale;
}
To create an x scale use x = pseudoLogScale() you can assign your own domain and range like this:
.domain([20, 20000])
.range([marginLeft, width - marginRight]);
In zoomed use:
new_x = x.rescaleX(transform); // Use the custom rescaleX method for pseudo-log
Then this to update the grid and xaxis:
// Pseudo-log scale
// Update the x-axis grid lines (logarithmic section)
xAxisGrid.call(d3.axisBottom(new_x)
.tickValues(new_x.gridLines(transform)) // Pass the transform for dynamic grid lines
.tickSize(originalXAxisTickSize) // Draw grid lines
.tickFormat("")); // No labels for the grid lines
// Update the x-axis with preferred tick values
xaxis.call(d3.axisBottom(new_x)
.ticks(8) // Use the 8 tick count for the overall axis
.tickFormat(new_x.tickFormat())); // Use the embedded tick formatter for labels
Many thanks