Use when working with SVG geometry diagrams for OGE/EGE math tasks — creating, editing, or debugging SVG, running svg:bake, or editing *_geometry.json files. Covers the Static SVG System and GEOMETRY_SPEC rules.
Проблема (была): SVG генерировались динамически, что приводило к разным результатам в разных местах.
Решение: Static SVG Baking — SVG генерируются ОДИН раз и сохраняются в JSON.
topic_XX_geometry.json ← РЕДАКТИРУЙ ЗДЕСЬ (точки, типы, параметры)
│
▼
php artisan svg:bake XX ← Команда генерации
│
▼
topic_XX.json ← Результат (SVG в task['svg']), используется на сайте
storage/app/tasks/
├── topic_15_geometry.json ← Источник (треугольники) — РЕДАКТИРУЙ
├── topic_15.json ← Результат с SVG — НЕ РЕДАКТИРУЙ вручную
├── topic_16_geometry.json ← Источник (окружности) — РЕДАКТИРУЙ
├── topic_16.json ← Результат с SVG — НЕ РЕДАКТИРУЙ вручную
└── topic_17.json ← Пока без geometry
| Файл | Назначение |
|---|---|
app/Console/Commands/BakeSvgToJson.php | Artisan-команда svg:bake |
app/Services/GeometrySvgRenderer.php | Рендерер SVG из geometry данных |
storage/app/tasks/topic_XX_geometry.json | Исходные geometry данные |
storage/app/tasks/topic_XX.json | Итоговый JSON с SVG строками |
php artisan svg:bake 16 # Перегенерировать SVG для темы
php artisan svg:bake-ege 13 # Для ЕГЭ
php artisan cache:clear # Очистить кэш после изменений
{
"topic_id": "16",
"meta": { "title": "Окружность", "description": "...", "color": "purple" },
"blocks": [{
"number": 1,
"title": "ФИПИ",
"zadaniya": [{
"number": 2,
"instruction": "Касательные к окружности",
"type": "geometry",
"svg_type": "tangent_lines",
"tasks": [{
"id": 9,
"text": "Условие задачи...",
"answer": "45",
"params": { "angle": 68 }
}]
}]
}]
}
Тема 15 (Треугольники): bisector, median, angles_sum, external_angle, isosceles, right_triangle, similar, midline, equilateral, circumcircle, trig, area_theorem
Тема 16 (Окружности): square_circle_vertex, tangent_lines, inscribed_angle, diameters, diameter_points, inscribed_trapezoid, inscribed_square, circumscribed_shapes, triangle_inscribed_circle, quad_in_circle, center_on_side, trapezoid_in_circle, sine_theorem
Файл: resources/views/tasks/types/geometry.blade.php
Приоритет: task['svg'] → task['image'] (inline SVG) → task['image'] (файл)
Для истинной касательной точка A должна удовлетворять условию OA ⊥ AP:
Дано: O=(ox, oy), P=(px, py), R=радиус
1. (ax - ox)² + (ay - oy)² = R² (точка на окружности)
2. (ax - ox)(px - ax) + (ay - oy)(py - ay) = 0 (OA ⊥ AP)
→ Решение даёт 2 точки касания
{x, y}0 0 300 220 для треугольников| Параметр | Стандартное значение |
|---|---|
max-w-[...] | 250px (всегда фиксированный!) |
viewBox | Пропорционален содержимому (200-275 по ширине) |
Правило масштабирования:
max-w-[250px] — ФИКСИРОВАННЫЙ размер для ВСЕХ диаграмм, НЕ менять!viewBox — масштабируется под содержимоеviewBox, НЕ менять max-w<svg viewBox="0 0 250 200" class="w-full max-w-[250px] h-auto">
<!-- содержимое -->
</svg>
Геометрическая фигура должна заполнять ~85% площади viewBox.
| viewBox | Стандартный размер | Заполнение фигуры |
|---|---|---|
0 0 220 200 | Универсальный | Ширина ~187px, высота ~170px |
Минимальные отступы от края viewBox:
Чек-лист при создании SVG:
0 0 220 200 (стандарт)250px (фиксированный)Геометрический рисунок должен иллюстрировать концепцию задачи, а НЕ буквально отображать числовые значения из условия.
Что показывает рисунок: тип фигуры, какие элементы даны, что требуется найти (? или зелёный), взаимное расположение.
Чего НЕ должен: точные пропорции углов/сторон, буквальное отображение экстремальных значений.
| Условие | Неправильно | Правильно |
|---|---|---|
| Угол C = 177° | Почти плоский треугольник | Нормальный треугольник с подписью |
| Угол = 3° | Едва видимый острый угол | Обычный острый угол с подписью |
| Радиус = 2√5 через точку A | Вычислять точный R (R = √12500 ≈ 112) | Подобрать R визуально (~60-70) |
| Окружность описана | Вычислять точный R = abc/4S | Визуально провести через вершины |
Принцип: Рисунок — это схема для понимания задачи, а не чертёж в масштабе. Радиус и положение центра подбираются так, чтобы вся фигура помещалась в viewBox с отступами минимум 5px.
| Элемент | Цвет | HEX |
|---|---|---|
| Основные линии (стороны) | Красный | #dc2626 |
| Highlight / известные значения | Янтарный | #f59e0b |
| Вспомогательные линии (медианы, биссектрисы, высоты) | Зелёный | #10b981 |
| Вспомогательные элементы (маркеры равенства) | Синий | #3b82f6 |
| Подписи точек | Голубой | #60a5fa |
| Второстепенный текст (длины сторон) | Серый | #94a3b8 |
| Прямой угол | Серый | #666666 |
function labelPos(point, center, distance = 22) {
const dx = point.x - center.x;
const dy = point.y - center.y;
const len = Math.sqrt(dx * dx + dy * dy);
if (len === 0) return { x: point.x, y: point.y - distance };
return {
x: point.x + (dx / len) * distance,
y: point.y + (dy / len) * distance
};
}
text-anchor="middle" и dominant-baseline="middle"function makeAngleArc(vertex, point1, point2, radius) {
const angle1 = Math.atan2(point1.y - vertex.y, point1.x - vertex.x);
const angle2 = Math.atan2(point2.y - vertex.y, point2.x - vertex.x);
const x1 = vertex.x + radius * Math.cos(angle1);
const y1 = vertex.y + radius * Math.sin(angle1);
const x2 = vertex.x + radius * Math.cos(angle2);
const y2 = vertex.y + radius * Math.sin(angle2);
let angleDiff = angle2 - angle1;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
const sweep = angleDiff > 0 ? 1 : 0;
return `M ${x1} ${y1} A ${radius} ${radius} 0 0 ${sweep} ${x2} ${y2}`;
}
vertex — вершина угла; point1, point2 — точки на сторонах углаfunction rightAnglePath(vertex, p1, p2, size = 12) {
const angle1 = Math.atan2(p1.y - vertex.y, p1.x - vertex.x);
const angle2 = Math.atan2(p2.y - vertex.y, p2.x - vertex.x);
const c1 = { x: vertex.x + size * Math.cos(angle1), y: vertex.y + size * Math.sin(angle1) };
const c2 = { x: vertex.x + size * Math.cos(angle2), y: vertex.y + size * Math.sin(angle2) };
const diag = { x: c1.x + size * Math.cos(angle2), y: c1.y + size * Math.sin(angle2) };
return `M ${c1.x} ${c1.y} L ${diag.x} ${diag.y} L ${c2.x} ${c2.y}`;
}
#666666 B
/|
/ | ← прямой угол в C
/ |
A---C
rightAnglePath(C, B, A, 15) — квадратик внутри (правильно)rightAnglePath(C, A, B, 15) — квадратик СНАРУЖИ (неправильно!)function isRightAngle(vertex, p1, p2) {
const v1 = { x: p1.x - vertex.x, y: p1.y - vertex.y };
const v2 = { x: p2.x - vertex.x, y: p2.y - vertex.y };
const dot = v1.x * v2.x + v1.y * v2.y;
return Math.abs(dot) < 1;
}
Всегда используйте isRightAngle() для определения вершины прямого угла, а не визуальную оценку координат.
function equalityTick(p1, p2, t = 0.5, length = 8) {
const mid = { x: p1.x + (p2.x - p1.x) * t, y: p1.y + (p2.y - p1.y) * t };
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const len = Math.sqrt(dx * dx + dy * dy);
const nx = -dy / len, ny = dx / len;
const half = length / 2;
return { x1: mid.x - nx * half, y1: mid.y - ny * half, x2: mid.x + nx * half, y2: mid.y + ny * half };
}
Черточка маркера ДОЛЖНА быть перпендикулярна стороне. НИКОГДА не используйте фиксированные смещения типа x - 4, y - 5.
function doubleEqualityTick(p1, p2, t = 0.5, length = 8, gap = 4) {
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const len = Math.sqrt(dx * dx + dy * dy);
const ux = dx / len, uy = dy / len;
const nx = -dy / len, ny = dx / len;
const mid = { x: p1.x + dx * t, y: p1.y + dy * t };
const half = length / 2, halfGap = gap / 2;
const tick1 = {
x1: mid.x - ux * halfGap - nx * half, y1: mid.y - uy * halfGap - ny * half,
x2: mid.x - ux * halfGap + nx * half, y2: mid.y - uy * halfGap + ny * half
};
const tick2 = {
x1: mid.x + ux * halfGap - nx * half, y1: mid.y + uy * halfGap - ny * half,
x2: mid.x + ux * halfGap + nx * half, y2: mid.y + uy * halfGap + ny * half
};
return { tick1, tick2 };
}
Правило: Разные пары равных отрезков — разное количество черточек:
| Пара | Черточки | Функция |
|---|---|---|
| Первая (AM = MB) | 1 | equalityTick() |
| Вторая (BN = NC) | 2 | doubleEqualityTick() |
function pointOnLine(p1, p2, t) {
return { x: p1.x + (p2.x - p1.x) * t, y: p1.y + (p2.y - p1.y) * t };
}
function labelOnSegment(p1, p2, offset = 15) {
const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const len = Math.sqrt(dx * dx + dy * dy);
const nx = -dy / len, ny = dx / len;
return { x: mid.x + nx * offset, y: mid.y + ny * offset };
}
function bisectorDirection(vertex, p1, p2) {
const dx1 = p1.x - vertex.x, dy1 = p1.y - vertex.y;
const len1 = Math.sqrt(dx1*dx1 + dy1*dy1);
const u1 = { x: dx1/len1, y: dy1/len1 };
const dx2 = p2.x - vertex.x, dy2 = p2.y - vertex.y;
const len2 = Math.sqrt(dx2*dx2 + dy2*dy2);
const u2 = { x: dx2/len2, y: dy2/len2 };
const bx = u1.x + u2.x, by = u1.y + u2.y;
const blen = Math.sqrt(bx*bx + by*by);
return { x: bx/blen, y: by/blen };
}
function bisectorEndpoint(vertex, p1, p2, targetP1, targetP2) {
const dir = bisectorDirection(vertex, p1, p2);
const intersection = raySegmentIntersection(vertex, dir, targetP1, targetP2);
if (intersection) return intersection;
return { x: vertex.x + dir.x * 200, y: vertex.y + dir.y * 200 };
}
function raySegmentIntersection(rayOrigin, rayDir, segP1, segP2) {
const dx = segP2.x - segP1.x, dy = segP2.y - segP1.y;
const denom = rayDir.x * dy - rayDir.y * dx;
if (Math.abs(denom) < 1e-10) return null;
const t = ((segP1.x - rayOrigin.x) * dy - (segP1.y - rayOrigin.y) * dx) / denom;
const s = ((segP1.x - rayOrigin.x) * rayDir.y - (segP1.y - rayOrigin.y) * rayDir.x) / denom;
if (t > 0 && s >= 0 && s <= 1) {
return { x: rayOrigin.x + t * rayDir.x, y: rayOrigin.y + t * rayDir.y };
}
return null;
}
function angleLabelPos(vertex, p1, p2, labelRadius, bias = 0.5) {
const angle1 = Math.atan2(p1.y - vertex.y, p1.x - vertex.x);
const angle2 = Math.atan2(p2.y - vertex.y, p2.x - vertex.x);
let diff = angle2 - angle1;
while (diff > Math.PI) diff -= 2 * Math.PI;
while (diff < -Math.PI) diff += 2 * Math.PI;
const midAngle = angle1 + diff * bias;
return { x: vertex.x + labelRadius * Math.cos(midAngle), y: vertex.y + labelRadius * Math.sin(midAngle) };
}
labelRadius должен быть больше радиуса дуги на 15-20pxВАЖНО: Углы с биссектрисой — метку полного угла размещать в половине угла (между стороной и биссектрисой), а не через angleLabelPos(vertex, p1, p2):
// Угол BAC = 68°, AD — биссектриса
const D = bisectorPoint(A, B, C);
angleLabelPos(A, B, D, 62, 0.6) // ✅ В половине угла BAD, bias=0.6
Рекомендуемые параметры: радиус дуги = 45px, labelRadius = 62px, bias = 0.6, радиус половинных дуг = 30px.
bisectorDirection() / bisectorEndpoint() — нельзя хардкодить!stroke-dasharray="6,4", цвет #10b981#10b981Альтернатива для треугольников:
function bisectorPointTriangle(A, B, C) {
const AB = Math.sqrt((B.x-A.x)**2 + (B.y-A.y)**2);
const AC = Math.sqrt((C.x-A.x)**2 + (C.y-A.y)**2);
return pointOnLine(B, C, AB / (AB + AC));
}
M = pointOnLine(A, C, 0.5)stroke-dasharray="6,4", цвет #10b981(B.x+M.x)/2 + 18, цвет #10b981#f59e0brightAnglePath()rightAnglePath(C, A, B, 15) — вершина первым аргументомlabelOnSegment()O.x - R > 5, O.x + R < width - 5, аналогично по Yy + 25, не через labelPos()<text :x="labelPos(A, center, 24).x" :y="labelPos(A, center, 24).y"
fill="#60a5fa" font-size="18" class="geo-label"
text-anchor="middle" dominant-baseline="middle">A</text>
.geo-line { transition: stroke 0.2s ease, stroke-width 0.2s ease; }
.geo-point { transition: r 0.2s ease, fill 0.2s ease; }
.geo-label { font-family: 'Times New Roman', serif; font-style: italic; font-weight: 500; user-select: none; pointer-events: none; }
labelPos(), не накладываются на фигуру(B.x+M.x)/2 + 18