feat(geom7 ch1): Wave 5 — Глава 1 «Начальные понятия геометрии» (§1-§7 + Финал)

Главное приобретение волны: библиотека geom7_svg.js — задел на ВСЮ
геометрию 7. 14 функций-хелперов:
- point, segment, ray, line — базовые примитивы с подписями/тиками
- circle с опц. центром, радиусом, подписью R
- arc, angle — дуги углов через atan2; кратчайший путь
- rightAngleMark — L-форма ВНУТРЬ угла (полилиния по двум направлениям)
- protractor — полукруглый транспортир с делениями каждые 10°
- polyline, polygon — ломаная/замкнутый полигон
- parallelMark — стрелочки на отрезках
- svgBox — обёртка с сеткой и фоном
- distance, midPoint, vec, unit, perp, rotate — математика
- renderMath — KaTeX с правильными делимитерами

Глава 1 — 7 § + Финал по учебнику Казакова 2022 (стр. 8-50):
- §1 Повторение 5-6 классов (длина, единицы, точка между двумя)
- §2 Предмет геометрии (аксиомы vs теоремы, планиметрия/стереометрия)
- §3 Прямая, луч, отрезок, ломаная (SVG-иллюстрации каждого)
- §4 Окружность и круг (свойство точки относительно окружности)
- §5 Угол и виды углов (острый/прямой/тупой/развёрнутый — SVG)
   + биссектриса
- §6 Смежные и вертикальные углы (с SVG: дополнительные лучи + пересечение)
- §7 Перпендикулярные прямые (теоремы единственности)

Интерактивы: 2-3 на §, всего 17:
- викторины с цветными кнопками (тип угла, аксиома/теорема, верно/нет)
- тренажёры (длины, углы, биссектрисы, перпендикуляры)

Финал: 5 боссов × 5 этапов = 25 этапов. Темы: §1-2, §3-4, §5, §6, §7.

amber-тема, KaTeX, sidebar-шпаргалка с формулами,
прогресс/XP, /api/textbooks/geometry-7-ch1/progress.

JS парсится OK (75 КБ), HTTP 200, 113 КБ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-29 07:53:10 +03:00
parent 995661158b
commit 2ffe376b2d
2 changed files with 1753 additions and 50 deletions
+263
View File
@@ -0,0 +1,263 @@
/* geom7_svg.js — библиотека SVG-хелперов для всей Геометрии 7
* Используется во всех 5 главах. Намеренно без зависимостей.
*
* Координатная система SVG: y растёт вниз — мы учитываем это в хелперах углов.
* Все функции возвращают строку SVG-фрагмента, готовую к вставке в innerHTML.
*
* Публичный API: window.GEOM7.{point,segment,ray,line,circle,arc,angle,
* rightAngleMark,protractor,polyline,polygon,parallelMark,
* midPoint,distance,vec,unit,perp,rotate,svgBox}
*/
(function(){
'use strict';
if (window.GEOM7 && window.GEOM7.__installed) return;
const G = window.GEOM7 = window.GEOM7 || {};
G.__installed = true;
/* === Математика === */
G.distance = (p1, p2) => Math.hypot(p2.x - p1.x, p2.y - p1.y);
G.midPoint = (p1, p2) => ({ x:(p1.x+p2.x)/2, y:(p1.y+p2.y)/2 });
G.vec = (p1, p2) => ({ x:p2.x-p1.x, y:p2.y-p1.y });
G.unit = (v) => { const L = Math.hypot(v.x, v.y) || 1; return { x:v.x/L, y:v.y/L }; };
G.perp = (v) => ({ x:-v.y, y:v.x });
G.rotate = (p, a) => ({ x:p.x*Math.cos(a) - p.y*Math.sin(a), y:p.x*Math.sin(a) + p.y*Math.cos(a) });
/* === SVG-обёртка (даёт область чертежа с фоном/сеткой) === */
G.svgBox = function(w, h, opts){
opts = opts || {};
const grid = opts.grid !== false;
const bg = opts.bg || '#fafafa';
const cellSize = opts.cell || 20;
let gridSvg = '';
if(grid){
gridSvg = '<defs><pattern id="g7-grid-'+(opts.id||'def')+'" width="'+cellSize+'" height="'+cellSize+'" patternUnits="userSpaceOnUse">'
+'<path d="M '+cellSize+' 0 L 0 0 0 '+cellSize+'" fill="none" stroke="#e2e8f0" stroke-width="0.6"/>'
+'</pattern></defs>'
+'<rect width="'+w+'" height="'+h+'" fill="url(#g7-grid-'+(opts.id||'def')+')"/>';
}
return { open:'<svg viewBox="0 0 '+w+' '+h+'" style="max-width:'+(opts.maxWidth||'100%')+';display:block;margin:'+(opts.margin||'0 auto')+';background:'+bg+';border-radius:10px;border:1px solid #e2e8f0">'+gridSvg, close:'</svg>' };
};
/* === Точка === */
G.point = function(x, y, label, opts){
opts = opts || {};
const r = opts.r || 4;
const color = opts.color || '#d97706';
const lx = x + (opts.dx !== undefined ? opts.dx : 8);
const ly = y + (opts.dy !== undefined ? opts.dy : -6);
let s = '<circle cx="'+x+'" cy="'+y+'" r="'+r+'" fill="'+color+'" stroke="#fff" stroke-width="1.5"/>';
if(label) s += '<text x="'+lx+'" y="'+ly+'" font-size="'+(opts.fontSize||14)+'" font-family="Unbounded,Inter,sans-serif" font-weight="800" fill="'+(opts.labelColor||color)+'">'+label+'</text>';
return s;
};
/* === Отрезок === */
G.segment = function(p1, p2, opts){
opts = opts || {};
const color = opts.color || '#0891b2';
const w = opts.width || 2.5;
let s = '<line x1="'+p1.x+'" y1="'+p1.y+'" x2="'+p2.x+'" y2="'+p2.y+'" stroke="'+color+'" stroke-width="'+w+'" stroke-linecap="round"';
if(opts.dash) s += ' stroke-dasharray="'+opts.dash+'"';
s += '/>';
/* Метки длины (ticks) */
if(opts.ticks){
const mid = G.midPoint(p1, p2);
const v = G.unit(G.vec(p1, p2));
const n = G.perp(v);
const tickLen = opts.tickLen || 5;
for(let i = 0; i < opts.ticks; i++){
const off = (i - (opts.ticks - 1)/2) * 4;
const cx = mid.x + v.x * off;
const cy = mid.y + v.y * off;
s += '<line x1="'+(cx + n.x * tickLen)+'" y1="'+(cy + n.y * tickLen)+'" x2="'+(cx - n.x * tickLen)+'" y2="'+(cy - n.y * tickLen)+'" stroke="'+color+'" stroke-width="1.5"/>';
}
}
/* Подпись длины */
if(opts.label){
const mid = G.midPoint(p1, p2);
const n = G.perp(G.unit(G.vec(p1, p2)));
const off = opts.labelOffset || 14;
s += '<text x="'+(mid.x + n.x * off)+'" y="'+(mid.y + n.y * off + 4)+'" text-anchor="middle" font-size="12" font-family="JetBrains Mono,monospace" fill="'+(opts.labelColor||'#475569')+'" font-weight="600">'+opts.label+'</text>';
}
return s;
};
/* === Луч (от p1 в направлении p2, продолжается за p2 до края — приблизительно) === */
G.ray = function(p1, p2, opts){
opts = opts || {};
const v = G.unit(G.vec(p1, p2));
const extend = opts.extend || 400; /* продолжить на N px за p2 */
const end = { x:p2.x + v.x * extend, y:p2.y + v.y * extend };
const color = opts.color || '#0891b2';
const w = opts.width || 2.5;
return '<line x1="'+p1.x+'" y1="'+p1.y+'" x2="'+end.x+'" y2="'+end.y+'" stroke="'+color+'" stroke-width="'+w+'" stroke-linecap="round"/>';
};
/* === Прямая (бесконечная — продолжается в обе стороны) === */
G.line = function(p1, p2, opts){
opts = opts || {};
const v = G.unit(G.vec(p1, p2));
const extend = opts.extend || 400;
const start = { x:p1.x - v.x * extend, y:p1.y - v.y * extend };
const end = { x:p2.x + v.x * extend, y:p2.y + v.y * extend };
return G.segment(start, end, opts);
};
/* === Окружность === */
G.circle = function(c, r, opts){
opts = opts || {};
const color = opts.color || '#7c3aed';
let s = '<circle cx="'+c.x+'" cy="'+c.y+'" r="'+r+'" fill="'+(opts.fill||'none')+'" stroke="'+color+'" stroke-width="'+(opts.width||2.5)+'"';
if(opts.dash) s += ' stroke-dasharray="'+opts.dash+'"';
s += '/>';
if(opts.center) s += G.point(c.x, c.y, opts.centerLabel || '', { color:color, dx:6, dy:-6 });
/* Радиус */
if(opts.radius){
const angle = (opts.radiusAngle !== undefined) ? opts.radiusAngle : Math.PI / 4;
const rEnd = { x:c.x + r * Math.cos(angle), y:c.y - r * Math.sin(angle) };
s += G.segment(c, rEnd, { color:color, width:1.5, dash:'4 3' });
if(opts.radiusLabel){
const mid = G.midPoint(c, rEnd);
s += '<text x="'+(mid.x+8)+'" y="'+(mid.y-4)+'" font-size="11" font-family="JetBrains Mono,monospace" fill="'+color+'" font-weight="600">'+opts.radiusLabel+'</text>';
}
}
return s;
};
/* === Дуга угла (от V с двумя направлениями) ===
* vAng1, vAng2 — углы в радианах (стандартная мат-конвенция: 0 = вправо, ↑ положительный CCW)
* НО: в SVG y вниз, поэтому работаем напрямую с углами SVG (0 = вправо, ↑ против часовой если y вверх).
* Мы передаём углы в SVG-конвенции (для нашей системы — y вниз).
*/
G.arc = function(c, r, a1, a2, opts){
opts = opts || {};
const x1 = c.x + r * Math.cos(a1), y1 = c.y + r * Math.sin(a1);
const x2 = c.x + r * Math.cos(a2), y2 = c.y + r * Math.sin(a2);
let delta = a2 - a1; while(delta < 0) delta += 2 * Math.PI; while(delta >= 2 * Math.PI) delta -= 2 * Math.PI;
const large = delta > Math.PI ? 1 : 0;
const sweep = opts.sweep !== undefined ? opts.sweep : 1;
const color = opts.color || '#dc2626';
return '<path d="M '+x1+' '+y1+' A '+r+' '+r+' 0 '+large+' '+sweep+' '+x2+' '+y2+'" fill="'+(opts.fill||'none')+'" stroke="'+color+'" stroke-width="'+(opts.width||2)+'"/>';
};
/* === Угол: вершина V, на которой угол; точки A, B — на сторонах. Возвращает дугу + (опционально) подпись. === */
G.angle = function(V, A, B, opts){
opts = opts || {};
const r = opts.r || 24;
const a1 = Math.atan2(A.y - V.y, A.x - V.x);
const a2 = Math.atan2(B.y - V.y, B.x - V.x);
/* Берём кратчайшую дугу */
let delta = a2 - a1; while(delta > Math.PI) delta -= 2 * Math.PI; while(delta < -Math.PI) delta += 2 * Math.PI;
const sweep = delta > 0 ? 1 : 0;
const large = Math.abs(delta) > Math.PI ? 1 : 0;
const x1 = V.x + r * Math.cos(a1), y1 = V.y + r * Math.sin(a1);
const x2 = V.x + r * Math.cos(a2), y2 = V.y + r * Math.sin(a2);
const color = opts.color || '#dc2626';
let s = '<path d="M '+x1+' '+y1+' A '+r+' '+r+' 0 '+large+' '+sweep+' '+x2+' '+y2+'" fill="'+(opts.fill||'none')+'" stroke="'+color+'" stroke-width="'+(opts.width||2)+'"/>';
if(opts.label){
/* Центр подписи — середина биссектрисы */
const midA = (a1 + a2) / 2 + (Math.abs(delta) > Math.PI ? Math.PI : 0);
const lr = r + (opts.labelOffset || 12);
const lx = V.x + lr * Math.cos(midA);
const ly = V.y + lr * Math.sin(midA);
s += '<text x="'+lx+'" y="'+(ly+5)+'" text-anchor="middle" font-size="'+(opts.fontSize||13)+'" font-family="Unbounded,Inter,sans-serif" font-weight="700" fill="'+color+'">'+opts.label+'</text>';
}
return s;
};
/* === Маркер прямого угла (L-форма ВНУТРЬ угла) === */
G.rightAngleMark = function(V, P1, P2, opts){
opts = opts || {};
const s = opts.size || 12;
const u1 = G.unit(G.vec(V, P1));
const u2 = G.unit(G.vec(V, P2));
const p1 = { x:V.x + s * u1.x, y:V.y + s * u1.y };
const c = { x:p1.x + s * u2.x, y:p1.y + s * u2.y };
const p2 = { x:V.x + s * u2.x, y:V.y + s * u2.y };
const color = opts.color || '#dc2626';
return '<polyline points="'+p1.x+','+p1.y+' '+c.x+','+c.y+' '+p2.x+','+p2.y+'" fill="none" stroke="'+color+'" stroke-width="'+(opts.width||1.8)+'"/>';
};
/* === Транспортир (полукруг 180°) === */
G.protractor = function(c, R, opts){
opts = opts || {};
const color = opts.color || '#0891b2';
const fill = opts.fill || '#fef3c7';
/* Полукруг по горизонтали (повернуть на 180°) */
let s = '<path d="M '+(c.x - R)+' '+c.y+' A '+R+' '+R+' 0 0 1 '+(c.x + R)+' '+c.y+' Z" fill="'+fill+'" stroke="'+color+'" stroke-width="2"/>';
/* Деления */
for(let deg = 0; deg <= 180; deg += 10){
const a = Math.PI - deg * Math.PI / 180; /* 180° → слева, 0° → справа */
const r2 = (deg % 30 === 0) ? R - 12 : R - 6;
const x1 = c.x + R * Math.cos(a), y1 = c.y - R * Math.sin(a);
const x2 = c.x + r2 * Math.cos(a), y2 = c.y - r2 * Math.sin(a);
s += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="'+color+'" stroke-width="1.2"/>';
if(deg % 30 === 0){
const xl = c.x + (R - 22) * Math.cos(a), yl = c.y - (R - 22) * Math.sin(a);
s += '<text x="'+xl+'" y="'+(yl+4)+'" text-anchor="middle" font-size="9" font-family="JetBrains Mono,monospace" fill="'+color+'">'+deg+'</text>';
}
}
/* Центральная точка */
s += G.point(c.x, c.y, '', { r:3, color:color });
return s;
};
/* === Ломаная === */
G.polyline = function(pts, opts){
opts = opts || {};
const color = opts.color || '#0891b2';
const ptsStr = pts.map(p => p.x + ',' + p.y).join(' ');
let s = '<polyline points="'+ptsStr+'" fill="none" stroke="'+color+'" stroke-width="'+(opts.width||2.5)+'" stroke-linecap="round" stroke-linejoin="round"';
if(opts.dash) s += ' stroke-dasharray="'+opts.dash+'"';
s += '/>';
if(opts.points) pts.forEach((p, i) => { s += G.point(p.x, p.y, opts.labels ? opts.labels[i] : '', { color:color }); });
return s;
};
/* === Полигон (замкнутый) === */
G.polygon = function(pts, opts){
opts = opts || {};
const color = opts.color || '#0891b2';
const ptsStr = pts.map(p => p.x + ',' + p.y).join(' ');
let s = '<polygon points="'+ptsStr+'" fill="'+(opts.fill||'rgba(8,145,178,.10)')+'" stroke="'+color+'" stroke-width="'+(opts.width||2.5)+'" stroke-linejoin="round"/>';
if(opts.points) pts.forEach((p, i) => { s += G.point(p.x, p.y, opts.labels ? opts.labels[i] : '', { color:color }); });
return s;
};
/* === Маркер параллельности (стрелки на отрезках) === */
G.parallelMark = function(p1, p2, opts){
opts = opts || {};
const count = opts.count || 1;
const v = G.unit(G.vec(p1, p2));
const n = G.perp(v);
const mid = G.midPoint(p1, p2);
const color = opts.color || '#7c3aed';
let s = '';
const arrow = 6;
for(let i = 0; i < count; i++){
const off = (i - (count - 1)/2) * 5;
const cx = mid.x + v.x * off;
const cy = mid.y + v.y * off;
/* Стрелка → > указывает в направлении v */
s += '<polyline points="'+(cx - v.x * arrow - n.x * arrow * 0.7)+','+(cy - v.y * arrow - n.y * arrow * 0.7)+' '+cx+','+cy+' '+(cx - v.x * arrow + n.x * arrow * 0.7)+','+(cy - v.y * arrow + n.y * arrow * 0.7)+'" fill="none" stroke="'+color+'" stroke-width="1.5" stroke-linecap="round"/>';
}
return s;
};
/* === KaTeX render (если доступен) — с правильными делимитерами === */
G.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){}
};
})();
File diff suppressed because it is too large Load Diff