Transformers-tenets / app /src /content /embeds /demo /d3-equation-editor.html
Molbap's picture
Molbap HF Staff
push a bunch of updates
e903a32
raw
history blame
26 kB
<div class="d3-equation-editor"></div>
<style>
.d3-equation-editor {
position: relative;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Arial, sans-serif;
}
.d3-equation-editor .chart-card {
background: var(--surface-bg);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px;
}
.d3-equation-editor .chart-header {
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 24px;
margin: 16px 0 0 0;
flex-wrap: wrap;
}
.d3-equation-editor .controls {
display: flex;
flex-direction: column;
gap: 24px;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
}
.d3-equation-editor .controls .control-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.d3-equation-editor .controls .control-group.equation-group {
width: 100%;
}
.d3-equation-editor .controls .input-row {
display: flex;
gap: 24px;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
width: 100%;
}
.d3-equation-editor .controls .input-row .control-group.equation-group {
flex: 1;
min-width: 300px;
}
.d3-equation-editor .controls .control-group.domain-group {
flex: 0 0 240px;
}
.d3-equation-editor .controls label {
font-size: 13px;
color: var(--text-color);
font-weight: 600;
letter-spacing: -0.01em;
}
.d3-equation-editor .controls input[type="text"] {
font-size: 17px;
font-weight: 400;
padding: 14px 18px;
border: 1.5px solid var(--primary-color);
border-radius: var(--button-radius);
background-color: var(--surface-bg);
color: var(--text-color);
cursor: text;
transition: all .2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
width: 100%;
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
line-height: 1.2;
}
[data-theme="dark"] .d3-equation-editor .controls input[type="text"] {
border: 1.5px solid var(--primary-color);
}
.d3-equation-editor .controls input[type="text"]:hover {
border-color: rgba(0, 123, 255, 0.3);
}
.d3-equation-editor .controls input[type="text"]:focus {
border-color: rgba(0, 123, 255, 0.6);
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
outline: none;
}
[data-theme="dark"] .d3-equation-editor .controls input[type="text"]:hover {
border-color: rgba(10, 132, 255, 0.4);
}
[data-theme="dark"] .d3-equation-editor .controls input[type="text"]:focus {
border-color: rgba(10, 132, 255, 0.7);
box-shadow: 0 0 0 3px rgba(10, 132, 255, 0.15);
}
.d3-equation-editor .controls input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
background: var(--border-color);
outline: none;
cursor: pointer;
width: 100%;
transition: background 0.2s ease;
}
.d3-equation-editor .controls input[type="range"]:hover {
background: var(--muted-color);
}
.d3-equation-editor .controls input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
border: 2px solid var(--page-bg);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.d3-equation-editor .controls input[type="range"]::-webkit-slider-thumb:hover {
background: var(--primary-color-hover);
transform: scale(1.1);
}
.d3-equation-editor .controls input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
border: 2px solid var(--page-bg);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.d3-equation-editor .controls input[type="range"]::-moz-range-thumb:hover {
background: var(--primary-color-hover);
transform: scale(1.1);
}
.d3-equation-editor .legend-bottom {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
font-size: 12px;
color: var(--text-color);
}
.d3-equation-editor .legend-bottom .legend-title {
font-size: 12px;
font-weight: 700;
color: var(--text-color);
}
.d3-equation-editor .legend-bottom .items {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
}
.d3-equation-editor .legend-bottom .item {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.d3-equation-editor .legend-bottom .swatch {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid var(--border-color);
display: inline-block;
}
.d3-equation-editor .axis-label {
fill: var(--text-color);
font-size: 12px;
font-weight: 700;
}
.d3-equation-editor .axes path,
.d3-equation-editor .axes line {
stroke: var(--axis-color);
}
.d3-equation-editor .axes text {
fill: var(--tick-color);
}
.d3-equation-editor .grid line {
stroke: var(--grid-color);
stroke-width: 1;
shape-rendering: crispEdges;
}
.d3-equation-editor .function-curve {
fill: none;
stroke-width: 2.5;
stroke-linejoin: round;
stroke-linecap: round;
}
.d3-equation-editor .d3-tooltip {
z-index: var(--z-tooltip);
backdrop-filter: saturate(1.12) blur(8px);
}
.d3-equation-editor .error-message {
color: var(--danger, #b00020);
font-size: 11px;
margin-top: 4px;
font-style: italic;
}
.d3-equation-editor .examples {
width: 100%;
}
.d3-equation-editor .examples .button {
margin: 0 10px 10px 0;
}
</style>
<script>
(() => {
const ensureD3 = (cb) => {
if (window.d3 && typeof window.d3.select === 'function') return cb();
let s = document.getElementById('d3-cdn-script');
if (!s) {
s = document.createElement('script');
s.id = 'd3-cdn-script';
s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
document.head.appendChild(s);
}
const onReady = () => {
if (window.d3 && typeof window.d3.select === 'function') cb();
};
s.addEventListener('load', onReady, { once: true });
if (window.d3) onReady();
};
const bootstrap = () => {
const scriptEl = document.currentScript;
let container = scriptEl ? scriptEl.previousElementSibling : null;
if (!(container && container.classList && container.classList.contains('d3-equation-editor'))) {
const candidates = Array.from(document.querySelectorAll('.d3-equation-editor'))
.filter(el => !(el.dataset && el.dataset.mounted === 'true'));
container = candidates[candidates.length - 1] || null;
}
if (!container) return;
if (container.dataset) {
if (container.dataset.mounted === 'true') return;
container.dataset.mounted = 'true';
}
// Controls
const controls = document.createElement('div');
controls.className = 'controls';
// Input row (equation + domain)
const inputRow = document.createElement('div');
inputRow.className = 'input-row';
// Equation input
const groupEquation = document.createElement('div');
groupEquation.className = 'control-group equation-group';
const labelEquation = document.createElement('label');
labelEquation.textContent = 'Equation f(x) =';
const inputEquation = document.createElement('input');
inputEquation.type = 'text';
inputEquation.value = 'sin(x) * exp(-x^2/8) + 0.3*sin(3*x)';
inputEquation.placeholder = 'e.g., sin(x)*exp(-x^2/8), x^3 - 3*x, sin(x) + cos(2*x)';
groupEquation.appendChild(labelEquation);
groupEquation.appendChild(inputEquation);
// Domain range
const groupRange = document.createElement('div');
groupRange.className = 'control-group domain-group';
const labelRange = document.createElement('label');
labelRange.textContent = 'Domain';
const inputRange = document.createElement('input');
inputRange.type = 'range';
inputRange.min = '1';
inputRange.max = '10';
inputRange.step = '0.5';
inputRange.value = '4';
const rangeValue = document.createElement('span');
rangeValue.style.fontSize = '13px';
rangeValue.style.color = 'var(--muted-color)';
rangeValue.style.fontWeight = '500';
rangeValue.style.marginTop = '4px';
rangeValue.textContent = '[-4π, 4π]';
groupRange.appendChild(labelRange);
groupRange.appendChild(inputRange);
groupRange.appendChild(rangeValue);
inputRow.appendChild(groupEquation);
inputRow.appendChild(groupRange);
// Examples (more focused and pertinent)
const examples = document.createElement('div');
examples.className = 'examples';
const exampleFunctions = [
'sin(x) * exp(-x^2/8)',
'sin(x) + 0.5*cos(2*x)',
'x^3 - 3*x',
'sin(x) * exp(-x^2/8) + 0.3*sin(3*x)',
'exp(-x^2/2) * cos(4*x)',
'sin(x) + sin(3*x)/3 + sin(5*x)/5'
];
exampleFunctions.forEach(func => {
const btn = document.createElement('button');
btn.className = 'button button--ghost';
btn.textContent = func;
btn.addEventListener('click', () => {
inputEquation.value = func;
updatePlot();
});
examples.appendChild(btn);
});
controls.appendChild(inputRow);
controls.appendChild(examples);
// Error message
const errorMsg = document.createElement('div');
errorMsg.className = 'error-message';
errorMsg.style.display = 'none';
// Header (controls only) to be placed after chart
const header = document.createElement('div');
header.className = 'chart-header';
header.appendChild(controls);
// SVG scaffolding inside a card wrapper
const card = document.createElement('div');
card.className = 'chart-card';
container.appendChild(card);
container.appendChild(header);
container.appendChild(errorMsg);
const svg = d3.select(card).append('svg').attr('width', '100%').style('display', 'block');
const gRoot = svg.append('g');
const gGrid = gRoot.append('g').attr('class', 'grid');
const gAxes = gRoot.append('g').attr('class', 'axes');
const gCurve = gRoot.append('g').attr('class', 'curve');
// Tooltip
container.style.position = container.style.position || 'relative';
let tip = container.querySelector('.d3-tooltip');
let tipInner;
if (!tip) {
tip = document.createElement('div');
tip.className = 'd3-tooltip';
Object.assign(tip.style, {
position: 'absolute',
top: '0px',
left: '0px',
transform: 'translate(-9999px, -9999px)',
pointerEvents: 'none',
padding: '8px 10px',
borderRadius: 'var(--button-radius)',
fontSize: '12px',
lineHeight: '1.35',
border: '1px solid var(--border-color)',
background: 'var(--surface-bg)',
color: 'var(--text-color)',
boxShadow: '0 4px 24px rgba(0,0,0,.18)',
opacity: '0',
transition: 'opacity .12s ease'
});
tipInner = document.createElement('div');
tipInner.className = 'd3-tooltip__inner';
tipInner.style.textAlign = 'left';
tip.appendChild(tipInner);
container.appendChild(tip);
} else {
tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
}
// State
let width = 800, height = 480;
const margin = { top: 16, right: 32, bottom: 44, left: 56 };
const xScale = d3.scaleLinear();
const yScale = d3.scaleLinear();
const line = d3.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y))
.curve(d3.curveCardinal);
// Math parser - improved to handle complex expressions and exponents correctly
function safeEval(expr, x) {
try {
// First, replace x with the actual value in parentheses for safety
let cleanExpr = expr.replace(/\bx\b/g, `(${x})`);
// Replace math functions and constants
cleanExpr = cleanExpr
.replace(/\bsin\b/g, 'Math.sin')
.replace(/\bcos\b/g, 'Math.cos')
.replace(/\btan\b/g, 'Math.tan')
.replace(/\bexp\b/g, 'Math.exp')
.replace(/\blog\b/g, 'Math.log')
.replace(/\babs\b/g, 'Math.abs')
.replace(/\bsqrt\b/g, 'Math.sqrt')
.replace(/\bpi\b/g, 'Math.PI')
.replace(/\be\b/g, 'Math.E');
// Handle exponents more carefully - need to preserve operator precedence
// Convert x^n to Math.pow(x, n) for proper precedence
cleanExpr = cleanExpr.replace(/([^*+\-\/\s]+)\^([^*+\-\/\s]+)/g, 'Math.pow($1, $2)');
// Handle remaining ^ operators (fallback to **)
cleanExpr = cleanExpr.replace(/\^/g, '**');
// Handle implicit multiplication (e.g., 2x -> 2*x, sin(x)cos(x) -> sin(x)*cos(x))
cleanExpr = cleanExpr
.replace(/(\d)(\()/g, '$1*$2') // 2( -> 2*(
.replace(/(\))(\()/g, '$1*$2') // )( -> )*(
.replace(/(\))(\d)/g, '$1*$2') // )2 -> )*2
.replace(/(\d)([a-zA-Z])/g, '$1*$2'); // 2x -> 2*x
// Security check: only allow safe mathematical operations
const safePattern = /^[0-9+\-*/.()Math\w\s,]*$/;
const withoutMath = cleanExpr.replace(/Math\.\w+/g, '');
if (!safePattern.test(withoutMath)) {
throw new Error('Invalid expression');
}
const result = eval(cleanExpr);
return isFinite(result) ? result : NaN;
} catch (e) {
return NaN;
}
}
function getColor() {
try {
if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
return window.ColorPalettes.getColors('categorical', 1)[0];
}
} catch (_) { }
return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#4e79a7';
}
function updateScales() {
width = container.clientWidth || 800;
height = Math.max(280, Math.round(width / 3));
svg.attr('width', width).attr('height', height);
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
const domainSize = parseFloat(inputRange.value);
const xDomain = [-domainSize * Math.PI, domainSize * Math.PI];
xScale.domain(xDomain).range([0, innerWidth]);
// Calculate y domain based on current function
const equation = inputEquation.value.trim();
if (equation) {
const testPoints = d3.range(xDomain[0], xDomain[1], (xDomain[1] - xDomain[0]) / 100);
const yValues = testPoints.map(x => safeEval(equation, x)).filter(y => !isNaN(y) && isFinite(y));
if (yValues.length > 0) {
const yExtent = d3.extent(yValues);
const yPadding = (yExtent[1] - yExtent[0]) * 0.1 || 1;
yScale.domain([yExtent[0] - yPadding, yExtent[1] + yPadding]).range([innerHeight, 0]);
} else {
yScale.domain([-2, 2]).range([innerHeight, 0]);
}
} else {
yScale.domain([-2, 2]).range([innerHeight, 0]);
}
// Grid
gGrid.selectAll('*').remove();
gGrid.selectAll('line.grid-x').data(xScale.ticks(8)).join('line')
.attr('class', 'grid-x')
.attr('x1', d => xScale(d)).attr('x2', d => xScale(d))
.attr('y1', 0).attr('y2', innerHeight)
.attr('stroke', 'var(--grid-color)')
.attr('stroke-width', 1)
.attr('shape-rendering', 'crispEdges');
gGrid.selectAll('line.grid-y').data(yScale.ticks(6)).join('line')
.attr('class', 'grid-y')
.attr('x1', 0).attr('x2', innerWidth)
.attr('y1', d => yScale(d)).attr('y2', d => yScale(d))
.attr('stroke', 'var(--grid-color)')
.attr('stroke-width', 1)
.attr('shape-rendering', 'crispEdges');
// Axes
gAxes.selectAll('*').remove();
gAxes.append('g')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale).ticks(8).tickFormat(d => {
const val = d / Math.PI;
if (Math.abs(val) < 0.01) return '0';
if (Math.abs(val - 1) < 0.01) return 'π';
if (Math.abs(val + 1) < 0.01) return '-π';
if (Math.abs(val - 0.5) < 0.01) return 'π/2';
if (Math.abs(val + 0.5) < 0.01) return '-π/2';
if (Math.abs(val % 1) < 0.01) return `${Math.round(val)}π`;
return d3.format('.1f')(val) + 'π';
}))
.call(g => {
g.selectAll('path, line').attr('stroke', 'var(--axis-color)');
g.selectAll('text').attr('fill', 'var(--tick-color)');
});
gAxes.append('g')
.call(d3.axisLeft(yScale).ticks(6))
.call(g => {
g.selectAll('path, line').attr('stroke', 'var(--axis-color)');
g.selectAll('text').attr('fill', 'var(--tick-color)');
});
// Axis labels
gAxes.append('text')
.attr('class', 'axis-label axis-label--x')
.attr('x', innerWidth / 2)
.attr('y', innerHeight + 44)
.attr('text-anchor', 'middle')
.text('x');
gAxes.append('text')
.attr('class', 'axis-label axis-label--y')
.attr('text-anchor', 'middle')
.attr('transform', `translate(${-44},${innerHeight / 2}) rotate(-90)`)
.text('f(x)');
return { innerWidth, innerHeight };
}
function updatePlot() {
errorMsg.style.display = 'none';
const equation = inputEquation.value.trim();
if (!equation) {
gCurve.selectAll('*').remove();
return;
}
updateScales();
// Generate data points
const domainSize = parseFloat(inputRange.value);
const xDomain = [-domainSize * Math.PI, domainSize * Math.PI];
const numPoints = Math.max(200, Math.min(1000, Math.round((xDomain[1] - xDomain[0]) * 50)));
const data = [];
let hasValidPoints = false;
let errorCount = 0;
for (let i = 0; i <= numPoints; i++) {
const x = xDomain[0] + (i / numPoints) * (xDomain[1] - xDomain[0]);
const y = safeEval(equation, x);
if (!isNaN(y) && isFinite(y)) {
data.push({ x, y });
hasValidPoints = true;
} else {
errorCount++;
}
}
if (!hasValidPoints) {
errorMsg.textContent = `Error: unable to evaluate equation "${equation}"`;
errorMsg.style.display = 'block';
gCurve.selectAll('*').remove();
return;
}
if (errorCount > numPoints * 0.5) {
errorMsg.textContent = `Warning: ${errorCount} invalid points out of ${numPoints}`;
errorMsg.style.display = 'block';
}
// Draw the curve
const color = getColor();
const path = gCurve.selectAll('path.function-curve').data([data]);
path.enter()
.append('path')
.attr('class', 'function-curve')
.attr('stroke', color)
.merge(path)
.transition()
.duration(150)
.attr('d', line)
.attr('stroke', color);
path.exit().remove();
// Hover interaction
const overlay = gCurve.selectAll('rect.overlay').data([0]);
const { innerWidth, innerHeight } = updateScales();
overlay.enter()
.append('rect')
.attr('class', 'overlay')
.attr('fill', 'transparent')
.style('cursor', 'crosshair')
.merge(overlay)
.attr('width', innerWidth)
.attr('height', innerHeight)
.on('mousemove', function (event) {
const [mx] = d3.pointer(event, this);
const x = xScale.invert(mx);
const y = safeEval(equation, x);
if (!isNaN(y) && isFinite(y)) {
tipInner.innerHTML = `
<div><strong>f(${x.toFixed(3)}) = ${y.toFixed(3)}</strong></div>
<div style="font-size:11px;color:var(--muted-color);margin-top:2px;">${equation}</div>
`;
tip.style.opacity = '1';
const tx = Math.max(0, Math.min(mx + margin.left + 12, (container.clientWidth || 0) - (tip.offsetWidth + 6)));
const ty = Math.max(0, Math.min(yScale(y) + margin.top + 12, (container.clientHeight || 0) - (tip.offsetHeight + 6)));
tip.style.transform = `translate(${Math.round(tx)}px, ${Math.round(ty)}px)`;
}
})
.on('mouseleave', function () {
tip.style.opacity = '0';
tip.style.transform = 'translate(-9999px, -9999px)';
});
}
// Event listeners
inputEquation.addEventListener('input', updatePlot);
inputRange.addEventListener('input', () => {
const val = parseFloat(inputRange.value);
rangeValue.textContent = `[-${val}π, ${val}π]`;
updatePlot();
});
// Initial setup (already done above)
// Initial render
updatePlot();
// Resize handling
const rerender = () => updatePlot();
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => rerender());
ro.observe(container);
} else {
window.addEventListener('resize', rerender);
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
} else {
ensureD3(bootstrap);
}
})();
</script>