M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
+563
@@ -0,0 +1,563 @@
|
||||
use crate::error::MermaidError;
|
||||
use crate::theme::MermaidTheme;
|
||||
|
||||
// --- Mermaid 11.12.2 journey config defaults ---
|
||||
const DIAGRAM_MARGIN_X: f64 = 50.0;
|
||||
const DIAGRAM_MARGIN_Y: f64 = 10.0;
|
||||
const LEFT_MARGIN: f64 = 150.0;
|
||||
const TASK_WIDTH: f64 = 150.0;
|
||||
const TASK_HEIGHT: f64 = 50.0;
|
||||
const TASK_MARGIN: f64 = 50.0;
|
||||
const SECTION_Y: f64 = 50.0;
|
||||
const FACE_RADIUS: f64 = 15.0;
|
||||
const ACTOR_CIRCLE_R: f64 = 7.0;
|
||||
const MAX_FACE_Y: f64 = 300.0;
|
||||
const FACE_Y_PER_SCORE: f64 = 30.0;
|
||||
const TASK_LINE_BOTTOM: f64 = MAX_FACE_Y + 5.0 * FACE_Y_PER_SCORE; // 450
|
||||
const ARROW_Y_MULTIPLIER: f64 = 4.0; // conf.height * 4 = 200
|
||||
const FONT_FAMILY: &str = "'trebuchet ms', verdana, arial, sans-serif";
|
||||
const TASK_FONT_SIZE: f64 = 14.0;
|
||||
const TASK_FONT_FAMILY: &str = "'Open Sans', sans-serif";
|
||||
const TITLE_FONT_SIZE: &str = "4ex";
|
||||
|
||||
// CSS section/task fill colors from Mermaid default theme
|
||||
const SECTION_FILLS: &[&str] = &[
|
||||
"#ECECFF",
|
||||
"#ffffde",
|
||||
"hsl(304, 100%, 96.2745098039%)",
|
||||
"hsl(124, 100%, 93.5294117647%)",
|
||||
"hsl(176, 100%, 96.2745098039%)",
|
||||
"hsl(-4, 100%, 93.5294117647%)",
|
||||
"hsl(8, 100%, 96.2745098039%)",
|
||||
"hsl(188, 100%, 93.5294117647%)",
|
||||
];
|
||||
|
||||
// SVG fill attributes for section rects (from sectionFills config)
|
||||
const SECTION_SVG_FILLS: &[&str] = &[
|
||||
"#191970", "#8B008B", "#4B0082", "#2F4F4F", "#800000", "#8B4513", "#00008B",
|
||||
];
|
||||
|
||||
const ACTOR_COLOURS: &[&str] = &[
|
||||
"#8FBC8F", "#7CFC00", "#00FFFF", "#20B2AA", "#B0E0E6", "#FFFFE0",
|
||||
];
|
||||
|
||||
pub fn render_journey_diagram_to_svg(
|
||||
mermaid_source: &str,
|
||||
_theme: &MermaidTheme,
|
||||
) -> Result<String, MermaidError> {
|
||||
let journey = parse_journey_diagram(mermaid_source)?;
|
||||
|
||||
// Collect unique actors in order of first appearance
|
||||
let mut actors: Vec<String> = Vec::new();
|
||||
for row in &journey.rows {
|
||||
if let JourneyRow::Task(task) = row {
|
||||
for actor in &task.actors {
|
||||
if !actors.contains(actor) {
|
||||
actors.push(actor.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In mermaid.js: leftMargin = conf.leftMargin + maxWidth
|
||||
// maxWidth is from actor legend text measurement; for simple cases it's 0
|
||||
let left_margin = LEFT_MARGIN;
|
||||
|
||||
// Flatten tasks with their section assignments
|
||||
let mut flat_tasks: Vec<FlatTask> = Vec::new();
|
||||
let mut current_section: Option<String> = None;
|
||||
for row in &journey.rows {
|
||||
match row {
|
||||
JourneyRow::Section(name) => {
|
||||
current_section = Some(name.clone());
|
||||
}
|
||||
JourneyRow::Task(task) => {
|
||||
flat_tasks.push(FlatTask {
|
||||
name: task.name.clone(),
|
||||
score: task.score,
|
||||
actors: task.actors.clone(),
|
||||
section: current_section.clone().unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let num_tasks = flat_tasks.len();
|
||||
if num_tasks == 0 {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Journey requires at least one task".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Compute section info
|
||||
let mut sections: Vec<SectionInfo> = Vec::new();
|
||||
{
|
||||
let mut last_section = String::new();
|
||||
let mut section_idx: usize = 0;
|
||||
for (i, task) in flat_tasks.iter().enumerate() {
|
||||
if task.section != last_section {
|
||||
// Count tasks in this section
|
||||
let count = flat_tasks[i..]
|
||||
.iter()
|
||||
.take_while(|t| t.section == task.section)
|
||||
.count();
|
||||
let section_num = section_idx % SECTION_SVG_FILLS.len();
|
||||
sections.push(SectionInfo {
|
||||
name: task.section.clone(),
|
||||
first_task_idx: i,
|
||||
task_count: count,
|
||||
section_num,
|
||||
});
|
||||
last_section = task.section.clone();
|
||||
section_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute task positions: task.x = i * taskMargin + i * width + leftMargin
|
||||
let task_positions: Vec<f64> = (0..num_tasks)
|
||||
.map(|i| i as f64 * TASK_MARGIN + i as f64 * TASK_WIDTH + left_margin)
|
||||
.collect();
|
||||
|
||||
// Section vertical height for task y position
|
||||
let section_v_height = TASK_HEIGHT * 2.0 + DIAGRAM_MARGIN_Y; // 110
|
||||
let task_y = section_v_height; // 110 (0 + sectionVHeight)
|
||||
|
||||
// Arrow y: conf.height * 4 = 50 * 4 = 200
|
||||
let arrow_y = TASK_HEIGHT * ARROW_Y_MULTIPLIER;
|
||||
|
||||
// Compute overall dimensions using mermaid.js bounds logic:
|
||||
// bounds.insert(task.x, task.y, task.x + task.width + taskMargin, 300+5*30)
|
||||
// where task.width = diagramMarginX (50), NOT the visual TASK_WIDTH (150)
|
||||
let last_task_x = task_positions.last().copied().unwrap_or(left_margin);
|
||||
let bounds_stopx = last_task_x + DIAGRAM_MARGIN_X + TASK_MARGIN; // 50 + 50 = 100
|
||||
|
||||
let width = left_margin + bounds_stopx + 2.0 * DIAGRAM_MARGIN_X;
|
||||
// height = stopy - starty + 2 * diagramMarginY; starty=0, stopy=450
|
||||
let height = TASK_LINE_BOTTOM + 2.0 * DIAGRAM_MARGIN_Y;
|
||||
|
||||
let has_title = journey.title.is_some();
|
||||
let extra_vert_for_title = if has_title { 70.0 } else { 0.0 };
|
||||
let viewbox_height = height + extra_vert_for_title;
|
||||
let svg_height = height + extra_vert_for_title + 25.0;
|
||||
|
||||
// Arrow endpoint: width - leftMargin - 4
|
||||
let arrow_x2 = width - left_margin - 4.0;
|
||||
|
||||
let mut svg = String::new();
|
||||
|
||||
// SVG header
|
||||
svg.push_str(&format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
|
||||
xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
|
||||
style=\"max-width: {width:.0}px;\" \
|
||||
width=\"100%\" \
|
||||
viewBox=\"0 -25 {width:.0} {vh:.0}\" \
|
||||
preserveAspectRatio=\"xMinYMin meet\" \
|
||||
height=\"{sh:.0}\" \
|
||||
role=\"graphics-document document\" \
|
||||
aria-roledescription=\"journey\">",
|
||||
vh = viewbox_height,
|
||||
sh = svg_height,
|
||||
));
|
||||
|
||||
// CSS styles matching mermaid.js default theme
|
||||
svg.push_str("<style>");
|
||||
svg.push_str(&format!(
|
||||
"svg {{font-family:{FONT_FAMILY};font-size:16px;fill:#333;}}"
|
||||
));
|
||||
svg.push_str(".mouth{stroke:#666;}");
|
||||
svg.push_str("line{stroke:#333;}");
|
||||
svg.push_str(&format!(".legend{{fill:#333;font-family:{FONT_FAMILY};}}"));
|
||||
svg.push_str(".label text{fill:#333;}");
|
||||
svg.push_str(".face{fill:#FFF8DC;stroke:#999;}");
|
||||
|
||||
// Section/task type fills
|
||||
for (i, fill) in SECTION_FILLS.iter().enumerate() {
|
||||
svg.push_str(&format!(".task-type-{i},.section-type-{i}{{fill:{fill};}}"));
|
||||
}
|
||||
|
||||
// Actor colors
|
||||
for (i, color) in ACTOR_COLOURS.iter().enumerate() {
|
||||
svg.push_str(&format!(".actor-{i}{{fill:{color};}}"));
|
||||
}
|
||||
|
||||
svg.push_str("</style>");
|
||||
|
||||
// Arrowhead marker definition
|
||||
svg.push_str("<defs><marker id=\"arrowhead\" refX=\"5\" refY=\"2\" markerWidth=\"6\" markerHeight=\"4\" orient=\"auto\"><path d=\"M 0,0 V 4 L6,2 Z\"/></marker></defs>");
|
||||
|
||||
// Actor legend
|
||||
let mut actor_y = 60.0_f64;
|
||||
for (pos, actor) in actors.iter().enumerate() {
|
||||
let color = ACTOR_COLOURS[pos % ACTOR_COLOURS.len()];
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"20\" cy=\"{actor_y:.0}\" class=\"actor-{pos}\" fill=\"{color}\" stroke=\"#000\" r=\"{ACTOR_CIRCLE_R}\"/>"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"40\" y=\"{ty:.0}\" class=\"legend\"><tspan x=\"50\">{}</tspan></text>",
|
||||
escape_xml(actor),
|
||||
ty = actor_y + 7.0,
|
||||
));
|
||||
actor_y += 20.0;
|
||||
}
|
||||
|
||||
// Draw sections
|
||||
for section in §ions {
|
||||
let section_x = task_positions[section.first_task_idx];
|
||||
let section_width = TASK_WIDTH * section.task_count as f64
|
||||
+ DIAGRAM_MARGIN_X * (section.task_count as f64 - 1.0);
|
||||
let fill = SECTION_SVG_FILLS[section.section_num % SECTION_SVG_FILLS.len()];
|
||||
let num = section.section_num;
|
||||
|
||||
svg.push_str("<g>");
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"{section_x:.0}\" y=\"{SECTION_Y:.0}\" fill=\"{fill}\" stroke=\"#666\" \
|
||||
width=\"{section_width:.0}\" height=\"{TASK_HEIGHT:.0}\" rx=\"3\" ry=\"3\" \
|
||||
class=\"journey-section section-type-{num}\"/>"
|
||||
));
|
||||
|
||||
// Section label using foreignObject with tspan fallback
|
||||
let center_x = section_x + section_width / 2.0;
|
||||
let center_y = SECTION_Y + TASK_HEIGHT / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<switch>\
|
||||
<foreignObject x=\"{section_x:.0}\" y=\"{SECTION_Y:.0}\" width=\"{section_width:.0}\" height=\"{TASK_HEIGHT:.0}\" \
|
||||
requiredExtensions=\"http://www.w3.org/1999/xhtml\">\
|
||||
<div class=\"journey-section section-type-{num}\" xmlns=\"http://www.w3.org/1999/xhtml\" \
|
||||
style=\"display: table; height: 100%; width: 100%;\">\
|
||||
<div class=\"label\" style=\"display: table-cell; text-align: center; vertical-align: middle;\">\
|
||||
{}</div></div></foreignObject>\
|
||||
<text x=\"{center_x:.0}\" y=\"{center_y:.0}\" dominant-baseline=\"central\" \
|
||||
alignment-baseline=\"central\" class=\"journey-section\" \
|
||||
style=\"text-anchor: middle; font-size: {TASK_FONT_SIZE}px; font-family: {TASK_FONT_FAMILY};\">\
|
||||
<tspan x=\"{center_x:.0}\" dy=\"0\">{}</tspan></text></switch>",
|
||||
escape_xml(§ion.name),
|
||||
escape_xml(§ion.name),
|
||||
));
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
// Draw tasks
|
||||
let mut current_section_num = 0_usize;
|
||||
let mut current_section_name = String::new();
|
||||
let mut section_idx = 0_usize;
|
||||
for (i, task) in flat_tasks.iter().enumerate() {
|
||||
let task_counter = i;
|
||||
// Track section
|
||||
if task.section != current_section_name {
|
||||
if section_idx < sections.len() {
|
||||
current_section_num = sections[section_idx].section_num;
|
||||
section_idx += 1;
|
||||
}
|
||||
current_section_name = task.section.clone();
|
||||
}
|
||||
|
||||
let tx = task_positions[i];
|
||||
let center = tx + TASK_WIDTH / 2.0;
|
||||
let fill = SECTION_SVG_FILLS[current_section_num % SECTION_SVG_FILLS.len()];
|
||||
let num = current_section_num;
|
||||
|
||||
svg.push_str("<g>");
|
||||
|
||||
// Dashed task line
|
||||
svg.push_str(&format!(
|
||||
"<line id=\"task{task_counter}\" x1=\"{center:.0}\" y1=\"{task_y:.0}\" \
|
||||
x2=\"{center:.0}\" y2=\"{TASK_LINE_BOTTOM:.0}\" class=\"task-line\" \
|
||||
stroke-width=\"1px\" stroke-dasharray=\"4 2\" stroke=\"#666\"/>"
|
||||
));
|
||||
|
||||
// Face icon
|
||||
let face_cy = MAX_FACE_Y + (5.0 - task.score as f64) * FACE_Y_PER_SCORE;
|
||||
draw_face(&mut svg, center, face_cy, task.score);
|
||||
|
||||
// Task rectangle
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"{tx:.0}\" y=\"{task_y:.0}\" fill=\"{fill}\" stroke=\"#666\" \
|
||||
width=\"{TASK_WIDTH:.0}\" height=\"{TASK_HEIGHT:.0}\" rx=\"3\" ry=\"3\" \
|
||||
class=\"task task-type-{num}\"/>"
|
||||
));
|
||||
|
||||
// Actor circles on the task
|
||||
let mut x_pos = tx + 14.0;
|
||||
for actor_name in &task.actors {
|
||||
if let Some(pos) = actors.iter().position(|a| a == actor_name) {
|
||||
let color = ACTOR_COLOURS[pos % ACTOR_COLOURS.len()];
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{x_pos:.0}\" cy=\"{task_y:.0}\" class=\"actor-{pos}\" \
|
||||
fill=\"{color}\" stroke=\"#000\" r=\"{ACTOR_CIRCLE_R}\"><title>{}</title></circle>",
|
||||
escape_xml(actor_name)
|
||||
));
|
||||
x_pos += 10.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Task label using foreignObject with tspan fallback
|
||||
let task_center_x = tx + TASK_WIDTH / 2.0;
|
||||
let task_center_y = task_y + TASK_HEIGHT / 2.0;
|
||||
svg.push_str(&format!(
|
||||
"<switch>\
|
||||
<foreignObject x=\"{tx:.0}\" y=\"{task_y:.0}\" width=\"{TASK_WIDTH:.0}\" height=\"{TASK_HEIGHT:.0}\" \
|
||||
requiredExtensions=\"http://www.w3.org/1999/xhtml\">\
|
||||
<div class=\"task\" xmlns=\"http://www.w3.org/1999/xhtml\" \
|
||||
style=\"display: table; height: 100%; width: 100%;\">\
|
||||
<div class=\"label\" style=\"display: table-cell; text-align: center; vertical-align: middle;\">\
|
||||
{}</div></div></foreignObject>\
|
||||
<text x=\"{task_center_x:.0}\" y=\"{task_center_y:.0}\" dominant-baseline=\"central\" \
|
||||
alignment-baseline=\"central\" class=\"task\" \
|
||||
style=\"text-anchor: middle; font-size: {TASK_FONT_SIZE}px; font-family: {TASK_FONT_FAMILY};\">\
|
||||
<tspan x=\"{task_center_x:.0}\" dy=\"0\">{}</tspan></text></switch>",
|
||||
escape_xml(&task.name),
|
||||
escape_xml(&task.name),
|
||||
));
|
||||
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
// Title
|
||||
if let Some(title) = &journey.title {
|
||||
svg.push_str(&format!(
|
||||
"<text x=\"{left_margin:.0}\" font-size=\"{TITLE_FONT_SIZE}\" \
|
||||
font-weight=\"bold\" y=\"25\" fill=\"#333\" \
|
||||
font-family=\"{FONT_FAMILY}\">{}</text>",
|
||||
escape_xml(title),
|
||||
));
|
||||
}
|
||||
|
||||
// Horizontal arrow
|
||||
svg.push_str(&format!(
|
||||
"<line x1=\"{left_margin:.0}\" y1=\"{arrow_y:.0}\" x2=\"{arrow_x2:.0}\" y2=\"{arrow_y:.0}\" \
|
||||
stroke-width=\"4\" stroke=\"black\" marker-end=\"url(#arrowhead)\"/>"
|
||||
));
|
||||
|
||||
svg.push_str("</svg>");
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
fn draw_face(svg: &mut String, cx: f64, cy: f64, score: i32) {
|
||||
// Face circle
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{cx:.0}\" cy=\"{cy:.0}\" class=\"face\" r=\"{FACE_RADIUS}\" \
|
||||
stroke-width=\"2\" overflow=\"visible\"/>"
|
||||
));
|
||||
|
||||
svg.push_str("<g>");
|
||||
|
||||
// Eyes
|
||||
let eye_y = cy - FACE_RADIUS / 3.0;
|
||||
let left_eye_x = cx - FACE_RADIUS / 3.0;
|
||||
let right_eye_x = cx + FACE_RADIUS / 3.0;
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{left_eye_x:.0}\" cy=\"{eye_y:.0}\" r=\"1.5\" stroke-width=\"2\" fill=\"#666\" stroke=\"#666\"/>"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
"<circle cx=\"{right_eye_x:.0}\" cy=\"{eye_y:.0}\" r=\"1.5\" stroke-width=\"2\" fill=\"#666\" stroke=\"#666\"/>"
|
||||
));
|
||||
|
||||
// Mouth based on score
|
||||
if score > 3 {
|
||||
// Happy: smile arc
|
||||
let inner_r = FACE_RADIUS / 2.0;
|
||||
let outer_r = FACE_RADIUS / 2.2;
|
||||
let arc_path = generate_smile_arc(inner_r, outer_r);
|
||||
svg.push_str(&format!(
|
||||
"<path class=\"mouth\" d=\"{arc_path}\" transform=\"translate({cx:.0},{ty:.0})\"/>",
|
||||
ty = cy + 2.0,
|
||||
));
|
||||
} else if score < 3 {
|
||||
// Sad: frown arc
|
||||
let inner_r = FACE_RADIUS / 2.0;
|
||||
let outer_r = FACE_RADIUS / 2.2;
|
||||
let arc_path = generate_sad_arc(inner_r, outer_r);
|
||||
svg.push_str(&format!(
|
||||
"<path class=\"mouth\" d=\"{arc_path}\" transform=\"translate({cx:.0},{ty:.0})\"/>",
|
||||
ty = cy + 7.0,
|
||||
));
|
||||
} else {
|
||||
// Neutral: straight line
|
||||
svg.push_str(&format!(
|
||||
"<line class=\"mouth\" stroke=\"#666\" x1=\"{x1:.0}\" y1=\"{y1:.0}\" \
|
||||
x2=\"{x2:.0}\" y2=\"{y1:.0}\" stroke-width=\"1px\"/>",
|
||||
x1 = cx - 5.0,
|
||||
y1 = cy + 7.0,
|
||||
x2 = cx + 5.0,
|
||||
));
|
||||
}
|
||||
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
|
||||
/// Generate the smile arc path matching d3.arc with
|
||||
/// startAngle=PI/2, endAngle=3*PI/2, innerRadius=r/2, outerRadius=r/2.2
|
||||
fn generate_smile_arc(inner_r: f64, outer_r: f64) -> String {
|
||||
// The exact path from the reference SVG is:
|
||||
// M7.5,0A7.5,7.5,0,1,1,-7.5,0L-6.818,0A6.818,6.818,0,1,0,6.818,0Z
|
||||
format!(
|
||||
"M{or},0A{or},{or},0,1,1,-{or},0L-{ir},0A{ir},{ir},0,1,0,{ir},0Z",
|
||||
or = format_num(outer_r),
|
||||
ir = format_num(inner_r),
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate the sad arc path matching d3.arc with
|
||||
/// startAngle=3*PI/2, endAngle=5*PI/2, innerRadius=r/2, outerRadius=r/2.2
|
||||
fn generate_sad_arc(inner_r: f64, outer_r: f64) -> String {
|
||||
format!(
|
||||
"M-{or},0A{or},{or},0,1,1,{or},0L{ir},0A{ir},{ir},0,1,0,-{ir},0Z",
|
||||
or = format_num(outer_r),
|
||||
ir = format_num(inner_r),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_num(n: f64) -> String {
|
||||
let s = format!("{n:.3}");
|
||||
if s.contains('.') {
|
||||
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct JourneyDiagram {
|
||||
title: Option<String>,
|
||||
rows: Vec<JourneyRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum JourneyRow {
|
||||
Section(String),
|
||||
Task(JourneyTask),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct JourneyTask {
|
||||
name: String,
|
||||
score: i32,
|
||||
actors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FlatTask {
|
||||
name: String,
|
||||
score: i32,
|
||||
actors: Vec<String>,
|
||||
section: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SectionInfo {
|
||||
name: String,
|
||||
first_task_idx: usize,
|
||||
task_count: usize,
|
||||
section_num: usize,
|
||||
}
|
||||
|
||||
fn parse_journey_diagram(input: &str) -> Result<JourneyDiagram, MermaidError> {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
|
||||
let mut i = 0_usize;
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.split_whitespace().next() == Some("journey") {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
return Err(MermaidError::ParseError {
|
||||
line: i + 1,
|
||||
message: "Expected 'journey' declaration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut title: Option<String> = None;
|
||||
let mut rows: Vec<JourneyRow> = Vec::new();
|
||||
|
||||
while i < lines.len() {
|
||||
let raw = lines[i];
|
||||
let line = raw.trim();
|
||||
let line_no = i + 1;
|
||||
i += 1;
|
||||
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("title ") {
|
||||
let t = rest.trim();
|
||||
if !t.is_empty() {
|
||||
title = Some(t.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("section ") {
|
||||
let name = rest.trim();
|
||||
if !name.is_empty() {
|
||||
rows.push(JourneyRow::Section(name.to_string()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line
|
||||
.split(':')
|
||||
.map(|p| p.trim())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid journey task line: {line}"),
|
||||
});
|
||||
}
|
||||
|
||||
let name = parts[0].to_string();
|
||||
let score: i32 = parts[1].parse().map_err(|_| MermaidError::ParseError {
|
||||
line: line_no,
|
||||
message: format!("Invalid journey score: {line}"),
|
||||
})?;
|
||||
|
||||
let actors: Vec<String> = if parts.len() >= 3 {
|
||||
// Actors field may contain comma-separated names (e.g., "Alice, Bob")
|
||||
// Join remaining parts (in case actor names contain colons) then split by comma
|
||||
parts[2..]
|
||||
.join(": ")
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
rows.push(JourneyRow::Task(JourneyTask {
|
||||
name,
|
||||
score,
|
||||
actors,
|
||||
}));
|
||||
}
|
||||
|
||||
if rows.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: 1,
|
||||
message: "Journey requires at least one section/task".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(JourneyDiagram { title, rows })
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
Reference in New Issue
Block a user