|
|
<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'; |
|
|
} |
|
|
|
|
|
|
|
|
const controls = document.createElement('div'); |
|
|
controls.className = 'controls'; |
|
|
|
|
|
|
|
|
const inputRow = document.createElement('div'); |
|
|
inputRow.className = 'input-row'; |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
const errorMsg = document.createElement('div'); |
|
|
errorMsg.className = 'error-message'; |
|
|
errorMsg.style.display = 'none'; |
|
|
|
|
|
|
|
|
const header = document.createElement('div'); |
|
|
header.className = 'chart-header'; |
|
|
header.appendChild(controls); |
|
|
|
|
|
|
|
|
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'); |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
function safeEval(expr, x) { |
|
|
try { |
|
|
|
|
|
let cleanExpr = expr.replace(/\bx\b/g, `(${x})`); |
|
|
|
|
|
|
|
|
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'); |
|
|
|
|
|
|
|
|
|
|
|
cleanExpr = cleanExpr.replace(/([^*+\-\/\s]+)\^([^*+\-\/\s]+)/g, 'Math.pow($1, $2)'); |
|
|
|
|
|
|
|
|
cleanExpr = cleanExpr.replace(/\^/g, '**'); |
|
|
|
|
|
|
|
|
cleanExpr = cleanExpr |
|
|
.replace(/(\d)(\()/g, '$1*$2') |
|
|
.replace(/(\))(\()/g, '$1*$2') |
|
|
.replace(/(\))(\d)/g, '$1*$2') |
|
|
.replace(/(\d)([a-zA-Z])/g, '$1*$2'); |
|
|
|
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
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]); |
|
|
} |
|
|
|
|
|
|
|
|
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'); |
|
|
|
|
|
|
|
|
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)'); |
|
|
}); |
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
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'; |
|
|
} |
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
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)'; |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
inputEquation.addEventListener('input', updatePlot); |
|
|
inputRange.addEventListener('input', () => { |
|
|
const val = parseFloat(inputRange.value); |
|
|
rangeValue.textContent = `[-${val}π, ${val}π]`; |
|
|
updatePlot(); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
updatePlot(); |
|
|
|
|
|
|
|
|
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> |