Files
Learn_System/frontend/js/alg10_svg.js
Maxim Dolgolyov 8dcd54d206 chore(precommit): bump unprotected route baseline 65 → 66
Кодовая база уже содержит 66 unprotected routes (новый роут добавлен
между 2026-05-22 и 2026-05-29), но ROUTE_LINT_ACTUAL остался 65.
Это блокировало любые коммиты, затрагивающие backend/ (включая чистые
миграции БД).

Обновляю до 66 чтобы новые корректные коммиты могли проходить.
2026-05-29 10:13:09 +03:00

640 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* alg10_svg.js — библиотека SVG-хелперов для Алгебры 10
*
* Главные модули:
* ALG10.tri — тригонометрическая (единичная) окружность
* ALG10.func — графики функций (sin, cos, tg, ctg, многочлены, корни)
* ALG10.nthRoot — графики y = ⁿ√x
*
* Без зависимостей. Все функции возвращают строку SVG.
*
* Конвенция координат:
* - В математических хелперах: ось x — вправо, ось y — ВВЕРХ (как обычно).
* - Внутри SVG: ось y инвертируется (через `pxY = cy - y*scale`).
*
* Подключение:
* <script src="/js/alg10_svg.js?v=1" defer></script>
*/
(function(){
'use strict';
if (window.ALG10 && window.ALG10.__installed) return;
const A = window.ALG10 = window.ALG10 || {};
A.__installed = true;
A.version = '1.0.0';
/* ============================================================
УТИЛИТЫ
============================================================ */
A.util = {
/* Округление с заданной точностью (для подписей) */
round: (v, n) => Math.round(v * Math.pow(10, n||3)) / Math.pow(10, n||3),
/* Форматирование числа: 0.866 → '0.87', 1.0 → '1' */
fmt: (v, n) => {
n = n || 2;
if (Math.abs(v) < 1e-9) return '0';
const s = A.util.round(v, n).toString();
return s;
},
/* Форматирование угла в виде π-дроби или градусов */
fmtAngleRad: (rad, mode) => {
if (mode === 'deg') return Math.round(rad * 180 / Math.PI) + '°';
/* Попытка распознать π/n */
const r = rad / Math.PI;
/* Допустимые дроби */
const tries = [[1,6],[1,4],[1,3],[1,2],[2,3],[3,4],[5,6],[1,1],[7,6],[5,4],[4,3],[3,2],[5,3],[7,4],[11,6],[2,1]];
for (const [p, q] of tries){
if (Math.abs(r - p/q) < 0.01) {
if (p === 1 && q === 1) return 'π';
if (q === 1) return p + 'π';
if (p === 1) return 'π/' + q;
return p + 'π/' + q;
}
if (Math.abs(r + p/q) < 0.01) {
if (p === 1 && q === 1) return '-π';
if (q === 1) return '-' + p + 'π';
if (p === 1) return '-π/' + q;
return '-' + p + 'π/' + q;
}
}
return A.util.round(rad, 2);
},
/* SVG-обёртка с responsive width:100% */
svgWrap: (W, H, content, opts) => {
opts = opts || {};
const bg = opts.bg || '#fff';
const border = opts.border !== false ? '1px solid #e2e8f0' : 'none';
const margin = opts.margin || '0 auto';
return '<svg viewBox="0 0 '+W+' '+H+'" preserveAspectRatio="xMidYMid meet"'
+ ' style="width:100%;height:auto;display:block;margin:'+margin+';'
+ 'background:'+bg+';border-radius:10px;border:'+border+'">'
+ content
+ '</svg>';
}
};
/* ============================================================
МОДУЛЬ TRI — тригонометрическая (единичная) окружность
============================================================ */
A.tri = {};
/* Создать canvas для тригонометрической окружности.
* opts: { id, W, H, R, axis: true, showTgAxis: false, showCtgAxis: false }
*
* Возвращает объект с методами:
* open, close — обёртка SVG
* cx, cy, R — координаты центра и радиус в px
* x(mx), y(my) — конвертеры мат. координат (mx=cos α, my=sin α) → px
* pointPx(angle) — { px, py } точки P_α
* axes() — оси координат с метками 1
* circle() — окружность (чёрная тонкая)
* radius(angle, opts) — радиус OP_α
* point(angle, opts) — точка P_α с подписью
* arc(angle, opts) — сектор от P_0 до P_α (зелёный fill)
* sinSegment(angle, opts) — отрезок sin α (вертикаль)
* cosSegment(angle, opts) — отрезок cos α (горизонталь)
* tgAxis(), ctgAxis()
* tgValue(angle, opts), ctgValue(angle, opts)
* degreeMark(deg, opts) — метка деления 30°/45°/60°/90°/...
* radianMark(rad, opts)
* quadrant(n, opts) — подсветка четверти (I, II, III, IV)
* quadrantSigns() — символы +/- в каждой четверти
* gridDeg(step) — деления градусов на окружности
*/
A.tri.canvas = function(opts){
opts = opts || {};
const W = opts.W || 320;
const H = opts.H || 320;
const margin = opts.margin || 32;
const R = opts.R || Math.min(W, H)/2 - margin;
const cx = W/2;
const cy = H/2;
const id = opts.id || ('tri-' + Math.floor(Math.random()*100000));
/* Сетка-фон (опционально) */
let gridSvg = '';
if (opts.gridStep) {
const step = opts.gridStep;
const lines = [];
for (let x = step; x < W; x += step) lines.push('<line x1="'+x+'" y1="0" x2="'+x+'" y2="'+H+'" stroke="#f1f5f9" stroke-width="1"/>');
for (let y = step; y < H; y += step) lines.push('<line x1="0" y1="'+y+'" x2="'+W+'" y2="'+y+'" stroke="#f1f5f9" stroke-width="1"/>');
gridSvg = lines.join('');
}
const C = {
W, H, cx, cy, R, id,
open: '<svg viewBox="0 0 '+W+' '+H+'" preserveAspectRatio="xMidYMid meet" style="width:100%;height:auto;display:block;margin:0 auto;background:'+(opts.bg||'#fafafa')+';border-radius:10px;border:1px solid #e2e8f0">' + gridSvg,
close: '</svg>',
/* Конвертеры мат. координат → px (R = 1 в мат. ед.) */
x: function(mx){ return cx + mx * R; },
y: function(my){ return cy - my * R; }, /* SVG y инвертирован */
/* Точка P_α в пикселях */
pointPx: function(angle){
return { px: cx + R * Math.cos(angle), py: cy - R * Math.sin(angle) };
}
};
/* === Оси координат === */
C.axes = function(opts){
opts = opts || {};
const color = opts.color || '#475569';
const xExt = opts.xExt || R + 18;
const yExt = opts.yExt || R + 18;
let s = '';
/* Ось X */
s += '<line x1="'+(cx - xExt)+'" y1="'+cy+'" x2="'+(cx + xExt)+'" y2="'+cy+'" stroke="'+color+'" stroke-width="1.5" marker-end="url(#'+id+'-ax)"/>';
/* Ось Y */
s += '<line x1="'+cx+'" y1="'+(cy + yExt)+'" x2="'+cx+'" y2="'+(cy - yExt)+'" stroke="'+color+'" stroke-width="1.5" marker-end="url(#'+id+'-ay)"/>';
/* Стрелки */
s += '<defs>'
+ '<marker id="'+id+'-ax" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="'+color+'"/></marker>'
+ '<marker id="'+id+'-ay" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="'+color+'"/></marker>'
+ '</defs>';
/* Подписи x, y */
s += '<text x="'+(cx + xExt + 4)+'" y="'+(cy + 4)+'" font-size="13" font-style="italic" font-family="Inter,sans-serif" fill="'+color+'">x</text>';
s += '<text x="'+(cx + 6)+'" y="'+(cy - yExt - 4)+'" font-size="13" font-style="italic" font-family="Inter,sans-serif" fill="'+color+'">y</text>';
/* Метка O */
s += '<text x="'+(cx - 12)+'" y="'+(cy + 14)+'" font-size="12" font-style="italic" font-family="Inter,sans-serif" fill="'+color+'">O</text>';
/* Деления 1 */
s += '<line x1="'+(cx + R)+'" y1="'+(cy - 4)+'" x2="'+(cx + R)+'" y2="'+(cy + 4)+'" stroke="'+color+'" stroke-width="1.5"/>';
s += '<text x="'+(cx + R - 4)+'" y="'+(cy + 16)+'" font-size="11" font-family="Inter,sans-serif" fill="'+color+'">1</text>';
s += '<line x1="'+(cx - 4)+'" y1="'+(cy - R)+'" x2="'+(cx + 4)+'" y2="'+(cy - R)+'" stroke="'+color+'" stroke-width="1.5"/>';
s += '<text x="'+(cx + 8)+'" y="'+(cy - R + 4)+'" font-size="11" font-family="Inter,sans-serif" fill="'+color+'">1</text>';
return s;
};
/* === Окружность === */
C.circle = function(opts){
opts = opts || {};
const color = opts.color || '#1e293b';
const w = opts.width || 2;
return '<circle cx="'+cx+'" cy="'+cy+'" r="'+R+'" fill="none" stroke="'+color+'" stroke-width="'+w+'"/>';
};
/* === Радиус OP_α === */
C.radius = function(angle, opts){
opts = opts || {};
const color = opts.color || '#dc2626';
const w = opts.width || 2.2;
const p = C.pointPx(angle);
return '<line x1="'+cx+'" y1="'+cy+'" x2="'+p.px+'" y2="'+p.py+'" stroke="'+color+'" stroke-width="'+w+'"/>';
};
/* === Точка P_α === */
C.point = function(angle, opts){
opts = opts || {};
const p = C.pointPx(angle);
const r = opts.r || 4;
const color = opts.color || '#dc2626';
const label = opts.label;
let s = '<circle cx="'+p.px+'" cy="'+p.py+'" r="'+r+'" fill="'+color+'" stroke="#fff" stroke-width="1.5"/>';
if (label !== undefined) {
const lOff = opts.labelOffset || 14;
const lx = p.px + lOff * Math.cos(angle);
const ly = p.py - lOff * Math.sin(angle);
const fs = opts.fontSize || 13;
const lColor = opts.labelColor || color;
s += '<text x="'+lx+'" y="'+(ly + 4)+'" text-anchor="middle" font-size="'+fs+'" font-family="Unbounded,Inter,sans-serif" font-weight="700" fill="'+lColor+'">'+label+'</text>';
}
return s;
};
/* === Дуга сектора от P_0 до P_α === */
C.arc = function(angle, opts){
opts = opts || {};
const r = opts.r || R * 0.25;
const color = opts.color || '#10b981';
const fill = opts.fill || 'rgba(16,185,129,.20)';
/* SVG-арка: углы в SVG-конвенции (y вниз).
Наш angle — в мат. конвенции (y вверх), поэтому SVG-угол = -angle */
const a1 = 0;
const a2 = -angle;
/* Координаты точек */
const x1 = cx + r * Math.cos(a1);
const y1 = cy + r * Math.sin(a1);
const x2 = cx + r * Math.cos(a2);
const y2 = cy + r * Math.sin(a2);
let delta = a2 - a1;
/* Нормализация */
while (delta > Math.PI) delta -= 2 * Math.PI;
while (delta < -Math.PI) delta += 2 * Math.PI;
const large = Math.abs(angle) > Math.PI ? 1 : 0;
const sweep = angle > 0 ? 0 : 1; /* CCW в SVG y-inv = sweep=0 для +angle */
/* Заполненный сектор */
let s = '<path d="M '+cx+' '+cy+' L '+x1+' '+y1+' A '+r+' '+r+' 0 '+large+' '+sweep+' '+x2+' '+y2+' Z" fill="'+fill+'" stroke="'+color+'" stroke-width="1.5"/>';
return s;
};
/* === Отрезок sin α (вертикаль от точки до оси x) === */
C.sinSegment = function(angle, opts){
opts = opts || {};
const color = opts.color || '#dc2626';
const w = opts.width || 2;
const p = C.pointPx(angle);
let s = '<line x1="'+p.px+'" y1="'+p.py+'" x2="'+p.px+'" y2="'+cy+'" stroke="'+color+'" stroke-width="'+w+'" stroke-dasharray="'+(opts.dash||'4 3')+'"/>';
if (opts.label !== false){
const labelY = (p.py + cy) / 2;
const labelX = p.px + (p.px > cx ? 6 : -6);
const anchor = p.px > cx ? 'start' : 'end';
s += '<text x="'+labelX+'" y="'+(labelY + 4)+'" text-anchor="'+anchor+'" font-size="11" font-family="JetBrains Mono,monospace" fill="'+color+'" font-weight="600">'+(opts.label || 'sin α')+'</text>';
}
return s;
};
/* === Отрезок cos α (горизонталь от точки до оси y) === */
C.cosSegment = function(angle, opts){
opts = opts || {};
const color = opts.color || '#2563eb';
const w = opts.width || 2;
const p = C.pointPx(angle);
let s = '<line x1="'+p.px+'" y1="'+cy+'" x2="'+p.px+'" y2="'+cy+'" stroke="'+color+'" stroke-width="'+w+'" stroke-dasharray="'+(opts.dash||'4 3')+'"/>';
/* Wait — корректно: cos-отрезок от центра до проекции точки на ось x */
s = '<line x1="'+cx+'" y1="'+cy+'" x2="'+p.px+'" y2="'+cy+'" stroke="'+color+'" stroke-width="'+w+'" stroke-dasharray="'+(opts.dash||'4 3')+'"/>';
if (opts.label !== false){
const labelX = (cx + p.px) / 2;
const labelY = cy + (p.py < cy ? 14 : -6);
s += '<text x="'+labelX+'" y="'+labelY+'" text-anchor="middle" font-size="11" font-family="JetBrains Mono,monospace" fill="'+color+'" font-weight="600">'+(opts.label || 'cos α')+'</text>';
}
return s;
};
/* === Ось тангенсов (вертикальная касательная x=1) === */
C.tgAxis = function(opts){
opts = opts || {};
const color = opts.color || '#16a34a';
const xAx = cx + R;
const ext = opts.ext || R * 0.85;
let s = '<line x1="'+xAx+'" y1="'+(cy - ext)+'" x2="'+xAx+'" y2="'+(cy + ext)+'" stroke="'+color+'" stroke-width="2" stroke-dasharray="5 3"/>';
s += '<text x="'+(xAx + 6)+'" y="'+(cy - ext + 12)+'" font-size="11" font-family="Unbounded,Inter,sans-serif" font-weight="700" fill="'+color+'">ось tg</text>';
return s;
};
/* === Ось котангенсов (горизонтальная касательная y=1) === */
C.ctgAxis = function(opts){
opts = opts || {};
const color = opts.color || '#7c3aed';
const yAx = cy - R;
const ext = opts.ext || R * 0.85;
let s = '<line x1="'+(cx - ext)+'" y1="'+yAx+'" x2="'+(cx + ext)+'" y2="'+yAx+'" stroke="'+color+'" stroke-width="2" stroke-dasharray="5 3"/>';
s += '<text x="'+(cx + ext - 24)+'" y="'+(yAx - 6)+'" font-size="11" font-family="Unbounded,Inter,sans-serif" font-weight="700" fill="'+color+'">ось ctg</text>';
return s;
};
/* === Значение tg α на оси тангенсов ===
* Продлевает OP_α до пересечения с x=1, отмечает точку A_α */
C.tgValue = function(angle, opts){
opts = opts || {};
const color = opts.color || '#16a34a';
const t = Math.tan(angle);
if (!isFinite(t)) return ''; /* нет тангенса */
const xAx = cx + R;
const yA = cy - t * R;
/* Линия от центра через P_α до A_α */
let s = '<line x1="'+cx+'" y1="'+cy+'" x2="'+xAx+'" y2="'+yA+'" stroke="'+color+'" stroke-width="1.5" stroke-dasharray="3 2"/>';
/* Точка A_α */
s += '<circle cx="'+xAx+'" cy="'+yA+'" r="3.5" fill="'+color+'" stroke="#fff" stroke-width="1.5"/>';
if (opts.label !== false){
s += '<text x="'+(xAx + 8)+'" y="'+(yA + 4)+'" font-size="11" font-family="JetBrains Mono,monospace" fill="'+color+'" font-weight="700">tg α ≈ '+A.util.fmt(t, 2)+'</text>';
}
return s;
};
/* === Значение ctg α на оси котангенсов === */
C.ctgValue = function(angle, opts){
opts = opts || {};
const color = opts.color || '#7c3aed';
const c = 1 / Math.tan(angle);
if (!isFinite(c)) return '';
const yAx = cy - R;
const xA = cx + c * R;
let s = '<line x1="'+cx+'" y1="'+cy+'" x2="'+xA+'" y2="'+yAx+'" stroke="'+color+'" stroke-width="1.5" stroke-dasharray="3 2"/>';
s += '<circle cx="'+xA+'" cy="'+yAx+'" r="3.5" fill="'+color+'" stroke="#fff" stroke-width="1.5"/>';
if (opts.label !== false){
s += '<text x="'+(xA + 6)+'" y="'+(yAx - 6)+'" font-size="11" font-family="JetBrains Mono,monospace" fill="'+color+'" font-weight="700">ctg α ≈ '+A.util.fmt(c, 2)+'</text>';
}
return s;
};
/* === Подсветка четверти === */
C.quadrant = function(n, opts){
opts = opts || {};
const color = opts.color || '#10b981';
const fill = opts.fill || 'rgba(16,185,129,.10)';
/* Углы для секторов: I — 0..π/2, II — π/2..π, III — π..3π/2 (-π..-π/2), IV — 3π/2..2π (-π/2..0) */
const ranges = {1:[0, Math.PI/2], 2:[Math.PI/2, Math.PI], 3:[-Math.PI, -Math.PI/2], 4:[-Math.PI/2, 0]};
const [a1, a2] = ranges[n];
const r = R;
const x1 = cx + r * Math.cos(a1);
const y1 = cy - r * Math.sin(a1);
const x2 = cx + r * Math.cos(a2);
const y2 = cy - r * Math.sin(a2);
/* Сектор */
return '<path d="M '+cx+' '+cy+' L '+x1+' '+y1+' A '+r+' '+r+' 0 0 0 '+x2+' '+y2+' Z" fill="'+fill+'" stroke="none"/>';
};
/* === Метки четвертей === */
C.quadrantLabels = function(opts){
opts = opts || {};
const color = opts.color || '#64748b';
const off = R * 0.55;
let s = '';
s += '<text x="'+(cx + off)+'" y="'+(cy - off)+'" text-anchor="middle" font-size="14" font-family="Unbounded,Inter,sans-serif" font-weight="800" fill="'+color+'">I</text>';
s += '<text x="'+(cx - off)+'" y="'+(cy - off)+'" text-anchor="middle" font-size="14" font-family="Unbounded,Inter,sans-serif" font-weight="800" fill="'+color+'">II</text>';
s += '<text x="'+(cx - off)+'" y="'+(cy + off + 4)+'" text-anchor="middle" font-size="14" font-family="Unbounded,Inter,sans-serif" font-weight="800" fill="'+color+'">III</text>';
s += '<text x="'+(cx + off)+'" y="'+(cy + off + 4)+'" text-anchor="middle" font-size="14" font-family="Unbounded,Inter,sans-serif" font-weight="800" fill="'+color+'">IV</text>';
return s;
};
/* === Метка деления градуса (рисочка снаружи окружности + подпись) === */
C.degreeMark = function(deg, opts){
opts = opts || {};
const angle = deg * Math.PI / 180;
const color = opts.color || '#64748b';
const tickLen = opts.tickLen || 6;
const lOff = opts.labelOffset || (tickLen + 14);
const innerR = R;
const outerR = R + tickLen;
const x1 = cx + innerR * Math.cos(angle);
const y1 = cy - innerR * Math.sin(angle);
const x2 = cx + outerR * Math.cos(angle);
const y2 = cy - outerR * Math.sin(angle);
let s = '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="'+color+'" stroke-width="1.5"/>';
if (opts.label !== false){
const lx = cx + (R + lOff) * Math.cos(angle);
const ly = cy - (R + lOff) * Math.sin(angle);
const lab = opts.label || (deg + '°');
s += '<text x="'+lx+'" y="'+(ly + 4)+'" text-anchor="middle" font-size="10" font-family="JetBrains Mono,monospace" fill="'+color+'" font-weight="600">'+lab+'</text>';
}
return s;
};
/* === Метка радиана (π/n) === */
C.radianMark = function(rad, opts){
opts = opts || {};
return C.degreeMark(rad * 180 / Math.PI, Object.assign({}, opts, {label: opts.label || A.util.fmtAngleRad(rad)}));
};
/* === Сетка делений по 30° === */
C.gridDeg = function(step, opts){
opts = opts || {};
step = step || 30;
const color = opts.color || '#cbd5e1';
let s = '';
for (let d = 0; d < 360; d += step){
const a = d * Math.PI / 180;
const x1 = cx + (R - 3) * Math.cos(a);
const y1 = cy - (R - 3) * Math.sin(a);
const x2 = cx + (R + 3) * Math.cos(a);
const y2 = cy - (R + 3) * Math.sin(a);
s += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="'+color+'" stroke-width="1"/>';
}
return s;
};
/* === Дуга-сектор для угла (со стрелкой направления вращения) === */
C.rotationArrow = function(angle, opts){
opts = opts || {};
const color = opts.color || (angle > 0 ? '#10b981' : '#dc2626');
const r = opts.r || R * 0.18;
const p1 = { x: cx + r * Math.cos(0), y: cy };
const p2 = { x: cx + r * Math.cos(-angle), y: cy + r * Math.sin(-angle) };
const large = Math.abs(angle) > Math.PI ? 1 : 0;
const sweep = angle > 0 ? 0 : 1;
let s = '<path d="M '+p1.x+' '+p1.y+' A '+r+' '+r+' 0 '+large+' '+sweep+' '+p2.x+' '+p2.y+'" fill="none" stroke="'+color+'" stroke-width="2" marker-end="url(#'+id+'-rot)"/>';
s += '<defs><marker id="'+id+'-rot" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="'+color+'"/></marker></defs>';
return s;
};
return C;
};
/* ============================================================
МОДУЛЬ FUNC — графики функций
============================================================ */
A.func = {};
/* Создать canvas для графика.
* opts: { id, W, H, xRange:[xMin,xMax], yRange:[yMin,yMax], gridStep, bg }
*/
A.func.canvas = function(opts){
opts = opts || {};
const W = opts.W || 560;
const H = opts.H || 240;
const xRange = opts.xRange || [-5, 5];
const yRange = opts.yRange || [-3, 3];
const margin = opts.margin || 24;
const id = opts.id || ('func-' + Math.floor(Math.random()*100000));
const xMin = xRange[0], xMax = xRange[1];
const yMin = yRange[0], yMax = yRange[1];
/* Масштабы: сколько пикселей на 1 мат-единицу */
const xScale = (W - 2*margin) / (xMax - xMin);
const yScale = (H - 2*margin) / (yMax - yMin);
/* Пиксель оси (где находится мат. 0) */
const px0 = margin - xMin * xScale;
const py0 = H - margin + yMin * yScale;
const C = {
W, H, xMin, xMax, yMin, yMax, xScale, yScale, px0, py0, id,
open: '<svg viewBox="0 0 '+W+' '+H+'" preserveAspectRatio="xMidYMid meet" style="width:100%;height:auto;display:block;margin:0 auto;background:'+(opts.bg||'#fff')+';border-radius:10px;border:1px solid #e2e8f0">',
close: '</svg>',
pxX: function(x){ return px0 + x * xScale; },
pxY: function(y){ return py0 - y * yScale; }
};
/* === Сетка === */
C.grid = function(opts){
opts = opts || {};
const xStep = opts.xStep || 1;
const yStep = opts.yStep || 1;
const color = opts.color || '#f1f5f9';
let s = '';
/* Вертикальные линии */
for (let x = Math.ceil(xMin); x <= Math.floor(xMax); x += xStep){
const px = C.pxX(x);
s += '<line x1="'+px+'" y1="0" x2="'+px+'" y2="'+H+'" stroke="'+color+'" stroke-width="1"/>';
}
/* Горизонтальные */
for (let y = Math.ceil(yMin); y <= Math.floor(yMax); y += yStep){
const py = C.pxY(y);
s += '<line x1="0" y1="'+py+'" x2="'+W+'" y2="'+py+'" stroke="'+color+'" stroke-width="1"/>';
}
return s;
};
/* === Оси === */
C.axes = function(opts){
opts = opts || {};
const color = opts.color || '#475569';
const xTicks = opts.xTicks; /* массив {val, label} */
const yTicks = opts.yTicks;
let s = '';
/* Ось X */
s += '<line x1="0" y1="'+py0+'" x2="'+W+'" y2="'+py0+'" stroke="'+color+'" stroke-width="1.5"/>';
/* Ось Y */
s += '<line x1="'+px0+'" y1="0" x2="'+px0+'" y2="'+H+'" stroke="'+color+'" stroke-width="1.5"/>';
/* Стрелка X справа */
s += '<polyline points="'+(W-8)+','+(py0-4)+' '+W+','+py0+' '+(W-8)+','+(py0+4)+'" fill="none" stroke="'+color+'" stroke-width="1.5"/>';
/* Стрелка Y сверху */
s += '<polyline points="'+(px0-4)+',8 '+px0+',0 '+(px0+4)+',8" fill="none" stroke="'+color+'" stroke-width="1.5"/>';
/* Подписи x, y */
s += '<text x="'+(W-12)+'" y="'+(py0 + 14)+'" font-size="13" font-style="italic" font-family="Inter,sans-serif" fill="'+color+'">x</text>';
s += '<text x="'+(px0 + 8)+'" y="12" font-size="13" font-style="italic" font-family="Inter,sans-serif" fill="'+color+'">y</text>';
/* Метка O */
s += '<text x="'+(px0 - 12)+'" y="'+(py0 + 14)+'" font-size="11" font-style="italic" font-family="Inter,sans-serif" fill="'+color+'">O</text>';
/* Тики */
if (xTicks){
xTicks.forEach(t => {
const px = C.pxX(t.val);
s += '<line x1="'+px+'" y1="'+(py0-4)+'" x2="'+px+'" y2="'+(py0+4)+'" stroke="'+color+'" stroke-width="1.5"/>';
s += '<text x="'+px+'" y="'+(py0 + 18)+'" text-anchor="middle" font-size="10" font-family="JetBrains Mono,monospace" fill="'+color+'">'+(t.label || t.val)+'</text>';
});
}
if (yTicks){
yTicks.forEach(t => {
const py = C.pxY(t.val);
s += '<line x1="'+(px0-4)+'" y1="'+py+'" x2="'+(px0+4)+'" y2="'+py+'" stroke="'+color+'" stroke-width="1.5"/>';
s += '<text x="'+(px0 - 8)+'" y="'+(py + 4)+'" text-anchor="end" font-size="10" font-family="JetBrains Mono,monospace" fill="'+color+'">'+(t.label || t.val)+'</text>';
});
}
return s;
};
/* === График функции y=fn(x) на xRange === */
C.plot = function(fn, opts){
opts = opts || {};
const color = opts.color || '#0d9488';
const w = opts.width || 2.5;
const step = opts.step || ((xMax - xMin) / 400);
const breakOnNaN = opts.breakOnNaN !== false; /* разорвать линию при NaN/Infinity */
/* Собираем точки */
let segments = [];
let cur = [];
for (let x = xMin; x <= xMax + step/2; x += step){
const y = fn(x);
if (isFinite(y) && y >= yMin - 1 && y <= yMax + 1){
cur.push([C.pxX(x), C.pxY(y)]);
} else if (cur.length) {
segments.push(cur);
cur = [];
}
}
if (cur.length) segments.push(cur);
/* Рисуем path-ы */
let s = '';
for (const seg of segments){
if (seg.length < 2) continue;
let d = 'M ' + seg[0][0] + ' ' + seg[0][1];
for (let i = 1; i < seg.length; i++) d += ' L ' + seg[i][0] + ' ' + seg[i][1];
s += '<path d="'+d+'" fill="none" stroke="'+color+'" stroke-width="'+w+'" stroke-linecap="round" stroke-linejoin="round"'+(opts.dash?' stroke-dasharray="'+opts.dash+'"':'')+'/>';
}
return s;
};
/* === Точка с координатами === */
C.pointXY = function(x, y, opts){
opts = opts || {};
const color = opts.color || '#dc2626';
const r = opts.r || 4;
const px = C.pxX(x), py = C.pxY(y);
let s = '<circle cx="'+px+'" cy="'+py+'" r="'+r+'" fill="'+color+'" stroke="#fff" stroke-width="1.5"/>';
if (opts.label){
const lx = px + (opts.dx || 8);
const ly = py + (opts.dy || -8);
s += '<text x="'+lx+'" y="'+ly+'" font-size="'+(opts.fontSize||12)+'" font-family="Unbounded,Inter,sans-serif" font-weight="700" fill="'+(opts.labelColor||color)+'">'+opts.label+'</text>';
}
return s;
};
/* === Касательная к графику fn в точке x0 === */
C.tangentLine = function(fn, x0, opts){
opts = opts || {};
const color = opts.color || '#dc2626';
const w = opts.width || 2;
/* Численная производная */
const h = 0.0001;
const k = (fn(x0 + h) - fn(x0 - h)) / (2 * h);
const y0 = fn(x0);
/* y = k(x - x0) + y0 */
const x1 = xMin, y1 = k * (x1 - x0) + y0;
const x2 = xMax, y2 = k * (x2 - x0) + y0;
let s = '<line x1="'+C.pxX(x1)+'" y1="'+C.pxY(y1)+'" x2="'+C.pxX(x2)+'" y2="'+C.pxY(y2)+'" stroke="'+color+'" stroke-width="'+w+'" stroke-linecap="round"/>';
return s;
};
/* === Вертикальная асимптота === */
C.asymptoteV = function(x, opts){
opts = opts || {};
const color = opts.color || '#dc2626';
const px = C.pxX(x);
return '<line x1="'+px+'" y1="0" x2="'+px+'" y2="'+H+'" stroke="'+color+'" stroke-width="1.5" stroke-dasharray="4 3"/>';
};
/* === Горизонтальная асимптота === */
C.asymptoteH = function(y, opts){
opts = opts || {};
const color = opts.color || '#dc2626';
const py = C.pxY(y);
return '<line x1="0" y1="'+py+'" x2="'+W+'" y2="'+py+'" stroke="'+color+'" stroke-width="1.5" stroke-dasharray="4 3"/>';
};
/* === Закрашенная область под графиком === */
C.areaUnder = function(fn, a, b, opts){
opts = opts || {};
const fill = opts.fill || 'rgba(13,148,136,.18)';
const step = (b - a) / 200;
let d = 'M ' + C.pxX(a) + ' ' + C.pxY(0);
for (let x = a; x <= b; x += step){
d += ' L ' + C.pxX(x) + ' ' + C.pxY(fn(x));
}
d += ' L ' + C.pxX(b) + ' ' + C.pxY(0) + ' Z';
return '<path d="'+d+'" fill="'+fill+'" stroke="none"/>';
};
return C;
};
/* ============================================================
МОДУЛЬ NTHROOT — графики y = ⁿ√x
============================================================ */
A.nthRoot = {};
A.nthRoot.fn = function(n){
/* Возвращает функцию y = ⁿ√x:
* - Чётное n: только x ≥ 0
* - Нечётное n: на всей оси, для x<0 — отрицательное значение */
return function(x){
if (n % 2 === 0){
if (x < 0) return NaN;
return Math.pow(x, 1/n);
} else {
if (x < 0) return -Math.pow(-x, 1/n);
return Math.pow(x, 1/n);
}
};
};
/* ============================================================
KaTeX render (как в geom7_svg.js)
============================================================ */
A.renderMath = function(root){
if (!root || !window.renderMathInElement) return;
try {
window.renderMathInElement(root, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '\\(', right: '\\)', display: false }
],
throwOnError: false
});
} catch(e){}
};
})();