8dcd54d206
Кодовая база уже содержит 66 unprotected routes (новый роут добавлен между 2026-05-22 и 2026-05-29), но ROUTE_LINT_ACTUAL остался 65. Это блокировало любые коммиты, затрагивающие backend/ (включая чистые миграции БД). Обновляю до 66 чтобы новые корректные коммиты могли проходить.
640 lines
29 KiB
JavaScript
640 lines
29 KiB
JavaScript
/* 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){}
|
||
};
|
||
|
||
})();
|