web: scroll to the top on navigation, organize the interface by layer, and set the formulas in the tool output

A page change did not move the scroll position, so following a link from
the bottom of a long document landed in the middle of the next one. The
scroll now follows the path rather than the fragment, so a filter applied
to the page a reader is on leaves them where they are. The browser fires
`popstate` for a fragment navigation as well as for a traversal, so
there is no event that means "this was a traversal" on its own, and going
back starts at the top rather than where the reader left.

The interface index listed fifteen crates in one grid. It is now grouped
by layer, with the title and order the concept documentation already
declares, and a card shows the opening sentence of the crate summary
rather than the whole of it, which is what made the cards four hundred
pixels tall.

A tool result showed its column headings, scalar labels and notes in the
reader's language, but left the table body in English. Text cells are now
looked up as well. Units are written with a power, and are set as one.

A tool description is Markdown, so it is now rendered as such, as are the
parameter help and the notes. A formula written between dollar signs is
set the same way in both places.
This commit is contained in:
dev 2026-10-01 22:46:56 +08:00
parent c812483f60
commit 0cc11b9af8
13 changed files with 517 additions and 30 deletions

View File

@ -17,7 +17,12 @@
SearchResponse, SearchResponse,
} from './lib/types'; } from './lib/types';
import { getJson, queryString } from './lib/api'; import { getJson, queryString } from './lib/api';
import { currentRoute, onRouteChange, navigate } from './lib/router'; import {
currentRoute,
navigate,
onRouteChange,
scrollToTopOnNavigation,
} from './lib/router';
import { language, pick, setLanguage, tr, type Language } from './lib/i18n'; import { language, pick, setLanguage, tr, type Language } from './lib/i18n';
import { theme, toggleTheme } from './lib/theme'; import { theme, toggleTheme } from './lib/theme';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
@ -134,10 +139,15 @@
$effect(() => { $effect(() => {
void loadShared(); void loadShared();
// A new page starts at the top; going back keeps the position.
const stopNavigation = scrollToTopOnNavigation();
const stop = onRouteChange(() => { const stop = onRouteChange(() => {
route = currentRoute(); route = currentRoute();
}); });
return stop; return () => {
stop();
stopNavigation();
};
}); });
$effect(() => { $effect(() => {

View File

@ -653,6 +653,44 @@ button.ghost:hover:not(:disabled) {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/*
* A card on the interface index shows what a crate is, in one or two lines.
*
* The cards there are in a grid with three columns on a wide screen, so a
* single long summary made every card in its row as tall as the longest one.
* The text is cut rather than the card stretched, because the rest of the
* summary is on the page the card leads to.
*/
.crate-grid .card-clamp {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
margin: 0.4rem 0 0;
color: var(--text-muted);
font-size: 0.9rem;
}
/* A layer heading on the interface index. */
.api-layer-heading {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin: 1.75rem 0 0;
font-size: 1.05rem;
}
.api-layer-heading a {
color: var(--text);
}
.api-layer-summary {
margin: 0.25rem 0 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.grid { .grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;

View File

@ -30,7 +30,9 @@
<div class="scalar"> <div class="scalar">
<span class="label">{pick(scalar.label, lang)}</span> <span class="label">{pick(scalar.label, lang)}</span>
<span class="value" style={scalar.monospace ? '' : 'font-family: var(--sans)'}> <span class="value" style={scalar.monospace ? '' : 'font-family: var(--sans)'}>
{scalar.value}{#if scalar.unit}<span class="unit">{scalar.unit}</span>{/if} {scalar.value}{#if scalar.unit}<span class="unit"
>{#if scalar.unit_html}{@html scalar.unit_html}{:else}{scalar.unit}{/if}</span
>{/if}
</span> </span>
</div> </div>
{/each} {/each}
@ -48,7 +50,9 @@
<tr> <tr>
{#each result.table.columns as column} {#each result.table.columns as column}
<th class={column.numeric ? 'numeric' : ''}> <th class={column.numeric ? 'numeric' : ''}>
{pick(column.label, lang)}{#if column.unit}<span class="unit"> [{column.unit}]</span>{/if} {pick(column.label, lang)}{#if column.unit}<span class="unit"
> [{#if column.unit_html}{@html column.unit_html}{:else}{column.unit}{/if}]</span
>{/if}
</th> </th>
{/each} {/each}
</tr> </tr>
@ -58,7 +62,18 @@
<tr> <tr>
{#each row as cell, index} {#each row as cell, index}
<td class={result.table.columns[index]?.numeric ? 'numeric' : ''}> <td class={result.table.columns[index]?.numeric ? 'numeric' : ''}>
{formatCell(cell, result.table.columns[index]?.numeric ?? false)} {#if typeof cell === 'object' && cell !== null && cell.html}
<!-- A cell written as notation, such as a unit with a
power in it. The HTML is rendered by the backend and
every text node is escaped there. -->
{@html cell.html}
{:else}
{formatCell(
cell,
result.table.columns[index]?.numeric ?? false,
lang,
)}
{/if}
</td> </td>
{/each} {/each}
</tr> </tr>
@ -69,9 +84,11 @@
{/if} {/if}
{#if result.notes.length > 0} {#if result.notes.length > 0}
<div class="notice muted"> <div class="notice muted doc-body">
{#each result.notes as note} {#each result.notes as note, index (index)}
<p>{pick(note, lang)}</p> <!-- The HTML is rendered by the backend from the note, and every text
node is escaped there. -->
{@html pick(result.notes_html?.[index] ?? { zh: note.zh, en: note.en }, lang)}
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@ -5,6 +5,9 @@
* number is written the same way everywhere. * number is written the same way everywhere.
*/ */
import type { Bilingual } from './types';
import { pick, type Language } from './i18n';
/** Formats a number with a sensible number of decimals for its magnitude. */ /** Formats a number with a sensible number of decimals for its magnitude. */
export function formatNumber(value: number | null | undefined): string { export function formatNumber(value: number | null | undefined): string {
if (value === null || value === undefined || !Number.isFinite(value)) { if (value === null || value === undefined || !Number.isFinite(value)) {
@ -21,11 +24,21 @@ export function formatNumber(value: number | null | undefined): string {
} }
/** Formats a table cell according to the column type. */ /** Formats a table cell according to the column type. */
export function formatCell(value: string | number | null, numeric: boolean): string { export function formatCell(
value: string | number | Bilingual | null,
numeric: boolean,
lang: Language,
): string {
if (value === null || value === undefined) return '—'; if (value === null || value === undefined) return '—';
if (typeof value === 'number') { if (typeof value === 'number') {
return numeric ? formatNumber(value) : String(value); return numeric ? formatNumber(value) : String(value);
} }
if (typeof value === 'object') {
// A text cell may carry a translation of a fixed phrase, such as the
// meaning of a field. A cell holding data rather than prose carries the
// same string in both languages and needs no lookup.
return pick(value, lang);
}
return value; return value;
} }

View File

@ -60,6 +60,43 @@ export function onRouteChange(listener: () => void): () => void {
return () => window.removeEventListener('hashchange', listener); return () => window.removeEventListener('hashchange', listener);
} }
/**
* Returns the page to the top when the route changes.
*
* A hash change does not move the scroll position, so a reader who has
* scrolled to the bottom of a long document and follows a link lands in the
* middle of the next page, with nothing to show that it is a different one.
*
* The scroll follows the path rather than the fragment, so that a search or a
* filter applied to the page a reader is already on leaves them where they
* are. Only a move to another page starts at the top.
*
* Going back also starts at the top rather than where the reader left. The
* browser would restore that position itself, but it cannot be told apart
* from an ordinary link: a fragment navigation creates a history entry, and
* the browser fires `popstate` for it as well as for a traversal, so there is
* no event that means "this was a traversal" on its own. Restoring the
* position by watching the path would mean keeping a history of positions,
* which is more machinery than the gain is worth here.
*/
export function scrollToTopOnNavigation(): () => void {
let previous: string | null = null;
const onChange = () => {
const path = window.location.hash.split('?')[0];
if (path === previous) return;
previous = path;
window.scrollTo(0, 0);
};
// The first page is already at the top, but the position is recorded so
// that moving away and back is treated as two changes rather than one.
previous = window.location.hash.split('?')[0];
window.addEventListener('hashchange', onChange);
return () => window.removeEventListener('hashchange', onChange);
}
/** /**
* Intercepts clicks on internal links so that the router handles them. * Intercepts clicks on internal links so that the router handles them.
* *

View File

@ -202,6 +202,11 @@ export interface BilingualSummary {
en: string; en: string;
plain: Bilingual; plain: Bilingual;
html: Bilingual; html: Bilingual;
/**
* The opening sentence, for a card that links onward. The rest of the
* summary belongs on the page the card leads to.
*/
brief?: Bilingual;
} }
/** One interface item. */ /** One interface item. */
@ -214,11 +219,20 @@ export interface ApiItem {
associated: boolean; associated: boolean;
} }
/** One layer of the library, used to organize the interface index. */
export interface ApiLayer {
id: string;
title: Bilingual;
summary: Bilingual;
}
/** The interface catalog index. */ /** The interface catalog index. */
export interface ApiIndex { export interface ApiIndex {
generated_from: string[]; generated_from: string[];
crate_count: number; crate_count: number;
item_count: number; item_count: number;
/** The layers, in reading order, that the crates are grouped by. */
layers: ApiLayer[];
crates: ApiCrateSummary[]; crates: ApiCrateSummary[];
items: { name: string; kind: string; crate: string }[]; items: { name: string; kind: string; crate: string }[];
} }
@ -244,6 +258,8 @@ export interface ToolParam {
name: string; name: string;
label: Bilingual; label: Bilingual;
help: Bilingual; help: Bilingual;
/** The help rendered from Markdown, so that a formula in it is set. */
help_html?: Bilingual;
kind: 'number' | 'integer' | 'text' | 'multiline' | 'select'; kind: 'number' | 'integer' | 'text' | 'multiline' | 'select';
required: boolean; required: boolean;
default: string | null; default: string | null;
@ -256,6 +272,8 @@ export interface Tool {
name: Bilingual; name: Bilingual;
summary: Bilingual; summary: Bilingual;
description: Bilingual; description: Bilingual;
/** The description rendered from Markdown, so that a formula in it is set. */
description_html?: Bilingual;
category: Bilingual; category: Bilingual;
params: ToolParam[]; params: ToolParam[];
api_items: string[]; api_items: string[];
@ -272,13 +290,36 @@ export interface ResultScalar {
label: Bilingual; label: Bilingual;
value: string; value: string;
unit: string; unit: string;
/** The unit rendered, so that a power in it reads as a power. */
unit_html?: string;
monospace: boolean; monospace: boolean;
} }
/** A column of a result table. */
export interface ResultColumn {
key: string;
label: Bilingual;
unit: string;
/** The unit rendered, so that a power in it reads as a power. */
unit_html?: string;
numeric: boolean;
}
/**
* One cell of a result table.
*
* A cell is usually a number. A text cell may carry a translation, because a
* cell holding a fixed phrase, such as the meaning of a field, is written
* once and shown in the reader's language. Such a cell may also carry the
* text rendered, which is how a unit written with a power in it, such as
* `rev/day^2`, is shown as a power.
*/
export type ResultCell = string | number | (Bilingual & { html?: string }) | null;
/** A result table. */ /** A result table. */
export interface ResultTable { export interface ResultTable {
columns: { key: string; label: Bilingual; unit: string; numeric: boolean }[]; columns: ResultColumn[];
rows: (string | number | null)[][]; rows: ResultCell[][];
} }
/** A numeric series. */ /** A numeric series. */
@ -296,6 +337,8 @@ export interface ToolResult {
table: ResultTable; table: ResultTable;
series: ResultSeries[]; series: ResultSeries[];
notes: Bilingual[]; notes: Bilingual[];
/** The notes rendered from Markdown, which is how a formula in one is set. */
notes_html?: Bilingual[];
axis?: { name: Bilingual; unit: string; values: number[] }; axis?: { name: Bilingual; unit: string; values: number[] };
} }

View File

@ -119,6 +119,35 @@
return names ? pick(names, language) : kind; return names ? pick(names, language) : kind;
} }
/**
* The crates of the index, grouped by the layer they belong to.
*
* The index is organized by layer for the same reason the concept
* documentation is: a reader looking for a type is looking for the subject
* it belongs to, and one flat list of fifteen crates gives no hint of where
* to look. The layers come from the concept documentation, so the order and
* the titles are the same in both.
*
* A layer the catalog names but the documentation does not declare would
* leave its crates with no heading, so those are collected at the end.
*/
const layers = $derived.by(() => {
const current = index;
if (!current) return [];
const declared = current.layers ?? [];
const grouped = declared
.map((layer) => ({
...layer,
crates: current.crates.filter((krate) => krate.layer === layer.id),
}))
.filter((layer) => layer.crates.length > 0);
const placed = new Set(declared.map((layer) => layer.id));
const rest = current.crates.filter((krate) => !placed.has(krate.layer));
return rest.length > 0
? [...grouped, { id: '', title: { zh: '其他', en: 'Others' }, summary: { zh: '', en: '' }, crates: rest }]
: grouped;
});
/** Opens an item route. */ /** Opens an item route. */
function openItem(name: string) { function openItem(name: string) {
navigate(`/api/${name}`); navigate(`/api/${name}`);
@ -263,18 +292,28 @@
{/if} {/if}
</section> </section>
{#each layers as layer (layer.id)}
<h3 class="api-layer-heading" id={`layer-${layer.id}`}>
<a href={`#/docs`}>{pick(layer.title, lang)}</a>
<span class="badge">{layer.crates.length}</span>
</h3>
{#if pick(layer.summary, lang)}
<p class="api-layer-summary">{pick(layer.summary, lang)}</p>
{/if}
<div class="crate-grid"> <div class="crate-grid">
{#each index.crates as candidate} {#each layer.crates as candidate (candidate.name)}
<a class="crate-card" href={`#/api/${candidate.name}`}> <a class="crate-card" href={`#/api/${candidate.name}`}>
<header> <header>
<span class="name">{candidate.name}</span> <span class="name">{candidate.name}</span>
<span class="badge">{candidate.layer}</span>
<span class="badge">{candidate.items} {tr('api.itemCount', lang)}</span> <span class="badge">{candidate.items} {tr('api.itemCount', lang)}</span>
</header> </header>
<p class="card-clamp">{pick(candidate.summary.plain, lang)}</p> <p class="card-clamp">
{pick(candidate.summary.brief ?? candidate.summary.plain, lang)}
</p>
</a> </a>
{/each} {/each}
</div> </div>
{/each}
{/if} {/if}
</div> </div>

View File

@ -148,7 +148,12 @@
<h1 style="margin: 0; border: none;">{pick(tool.name, lang)}</h1> <h1 style="margin: 0; border: none;">{pick(tool.name, lang)}</h1>
<span class="badge">{pick(tool.category, lang)}</span> <span class="badge">{pick(tool.category, lang)}</span>
</header> </header>
<p style="color: var(--text-muted)">{pick(tool.description, lang)}</p> <!-- The HTML is rendered by the backend from Markdown, and every text node
is escaped there. A formula written between dollar signs is set the same
way as one in a concept document. -->
<div class="doc-body" style="color: var(--text-muted)">
{@html pick(tool.description_html ?? { zh: tool.description.zh, en: tool.description.en }, lang)}
</div>
<form <form
onsubmit={(event) => { onsubmit={(event) => {
@ -185,7 +190,14 @@
{:else} {:else}
<input id={`field-${param.name}`} type="text" bind:value={values[param.name]} /> <input id={`field-${param.name}`} type="text" bind:value={values[param.name]} />
{/if} {/if}
<span class="help">{pick(param.help, lang)}</span> <!-- The HTML is rendered by the backend from Markdown, and every
text node is escaped there. -->
<span class="help doc-body"
>{@html pick(
param.help_html ?? { zh: param.help.zh, en: param.help.en },
lang,
)}</span
>
{#if param.constraints.unit} {#if param.constraints.unit}
<span class="unit">{tr('tools.unit', lang)}: {param.constraints.unit}</span> <span class="unit">{tr('tools.unit', lang)}: {param.constraints.unit}</span>
{/if} {/if}

View File

@ -274,6 +274,10 @@ impl Router {
"generated_from" => catalog.generated_from.iter().map(|path| Value::from(path.as_str())).collect::<Vec<Value>>(), "generated_from" => catalog.generated_from.iter().map(|path| Value::from(path.as_str())).collect::<Vec<Value>>(),
"crate_count" => catalog.crates.len(), "crate_count" => catalog.crates.len(),
"item_count" => catalog.item_count(), "item_count" => catalog.item_count(),
// The layers the crates belong to, in reading order, so that the
// index is organized the same way as the concept documentation
// rather than as one flat list.
"layers" => api_layers(&self.state.docs),
"crates" => crates, "crates" => crates,
"items" => items, "items" => items,
}; };
@ -566,6 +570,12 @@ fn crate_texts(krate: &crate::docs::ApiCrate, items: &std::collections::HashSet<
"zh" => crate_summary_plain(zh), "zh" => crate_summary_plain(zh),
"en" => crate_summary_plain(&krate.summary), "en" => crate_summary_plain(&krate.summary),
}, },
// The index card takes the opening sentence; the crate page shows the
// whole summary under `html`.
"brief" => json_object! {
"zh" => crate_summary_brief(zh),
"en" => crate_summary_brief(&krate.summary),
},
"html" => json_object! { "html" => json_object! {
"zh" => crate::docs::markdown::render_with(zh, Some(items)), "zh" => crate::docs::markdown::render_with(zh, Some(items)),
"en" => crate::docs::markdown::render_with(&krate.summary, Some(items)), "en" => crate::docs::markdown::render_with(&krate.summary, Some(items)),
@ -581,6 +591,78 @@ fn crate_summary_plain(summary: &str) -> String {
crate::docs::markdown::to_plain_text(summary) crate::docs::markdown::to_plain_text(summary)
} }
/// Returns the first sentence of a summary, for a card that links onward.
///
/// Returns the layers of the concept documentation, in reading order.
///
/// The interface catalog records which layer each crate belongs to. The index
/// groups the crates by that layer, so it needs the layer's title and its
/// place in the reading order, both of which the concept documentation
/// already declares.
fn api_layers(docs: &crate::docs::Tree) -> Value {
let layers: Vec<Value> = docs
.groups()
.iter()
.flat_map(|group| group.sections.iter())
.map(|section| {
json_object! {
"id" => section.id.clone(),
"title" => optional_bilingual(&section.title_zh, &section.title),
"summary" => optional_bilingual(&section.summary_zh, &section.summary),
}
})
.collect();
Value::Array(layers)
}
/// Returns the first sentence of a summary, for a card that links onward.
///
/// The crate summary is the whole of the crate documentation, which runs to
/// several paragraphs and a list of what the crate holds. A card on the index
/// shows what a crate is, and the crate page shows the rest, so the card takes
/// the opening sentence only. Without this the index cards are as tall as the
/// longest crate document and the page reads as a wall.
fn crate_summary_brief(summary: &str) -> String {
let text = crate_summary_plain(summary);
let text = text.trim();
// A sentence ends at the first full stop that is not part of a number or
// an abbreviation, which keeps `1.5` and `e.g.` from cutting a sentence
// in half.
let mut end = None;
for (index, character) in text.char_indices() {
// A Chinese sentence ends at its own full stop and is not followed by
// a space, so those three end a sentence wherever they fall.
if matches!(character, '\u{3002}' | '\u{ff01}' | '\u{ff1f}') {
end = Some(index + character.len_utf8());
break;
}
if character == '.' {
let before = text[..index].chars().next_back();
let after = text[index + 1..].chars().next();
let ends_number = before.is_some_and(|c| c.is_ascii_digit())
&& after.is_some_and(|c| c.is_ascii_digit());
let next_is_space = after.is_none_or(|c| c.is_whitespace());
if !ends_number && next_is_space {
end = Some(index + 1);
break;
}
}
}
let first = match end {
Some(index) => &text[..index],
None => text,
};
let first = first.trim();
// A summary with no full stop would otherwise put a whole paragraph on a
// card, so the length is capped as well.
if first.chars().count() <= 160 {
return first.to_string();
}
let mut cut: String = first.chars().take(157).collect();
cut.push('…');
cut
}
/// Encodes a document title or summary in both languages. /// Encodes a document title or summary in both languages.
/// ///
/// The two names select the field of a document; an unknown name yields an /// The two names select the field of a document; an unknown name yields an
@ -1039,4 +1121,32 @@ mod tests {
Status::MethodNotAllowed Status::MethodNotAllowed
); );
} }
#[test]
fn a_crate_card_takes_the_first_sentence_only() {
// The index shows what a crate is; the crate page shows the rest.
assert_eq!(
crate_summary_brief(
"ApsisRS \u{7684}\u{5171}\u{4eab}\u{57fa}\u{7840}\u{7c7b}\u{578b}\u{3002}\u{672c} crate \u{4f4d}\u{4e8e}\u{4f9d}\u{8d56}\u{56fe}\u{7684}\u{5e95}\u{5c42}\u{3002}"
),
"ApsisRS \u{7684}\u{5171}\u{4eab}\u{57fa}\u{7840}\u{7c7b}\u{578b}\u{3002}"
);
}
#[test]
fn a_number_is_not_a_sentence_end() {
// A version or a measurement must not cut a sentence in half.
assert_eq!(
crate_summary_brief("Reads version 1.5 of the format. Then it checksums."),
"Reads version 1.5 of the format."
);
}
#[test]
fn a_summary_without_a_full_stop_is_capped() {
let long = "word ".repeat(80);
let brief = crate_summary_brief(&long);
assert!(brief.chars().count() <= 158, "the brief is too long");
assert!(brief.ends_with('\u{2026}'), "it should be marked as cut");
}
} }

View File

@ -501,6 +501,13 @@ fn emit(node: &Node) -> String {
(Some(single), None) if single.is_alphabetic() => { (Some(single), None) if single.is_alphabetic() => {
format!("<mi>{}</mi>", escape(text)) format!("<mi>{}</mi>", escape(text))
} }
// An operator is written as one rather than as bare text. A
// browser sets text inside a row as ordinary glyphs with no
// spacing around it, so `rev/day` came out as `revday`; an
// operator carries its own spacing and cannot be swallowed.
(Some(single), None) if single.is_ascii_punctuation() => {
format!("<mo>{}</mo>", escape(text))
}
_ => escape(text), _ => escape(text),
} }
} }
@ -600,10 +607,11 @@ mod tests {
#[test] #[test]
fn a_comma_is_kept() { fn a_comma_is_kept() {
// The command is the spacing one, so dropping it would run a list of // The command is the spacing one, so dropping it would run a list of
// symbols together. // symbols together. It is written as an operator so that it keeps its
// space, which is what a comma is for.
assert_eq!( assert_eq!(
mathml("\\alpha, \\beta"), mathml("\\alpha, \\beta"),
"<mrow><mi>α</mi>, <mi>β</mi></mrow>" "<mrow><mi>α</mi><mo>,</mo> <mi>β</mi></mrow>"
); );
} }

View File

@ -90,6 +90,9 @@ const PAIRS: &[(&str, &str)] = &[
), ),
("Semi-major axis estimate", "半长轴估计"), ("Semi-major axis estimate", "半长轴估计"),
("Unit", "单位"), ("Unit", "单位"),
("first", "第一次"),
("Two-line element set", "两行元素集"),
("second", "第二次"),
// Propagation // Propagation
("Branch", "分支"), ("Branch", "分支"),
("Minutes from epoch", "距历元分钟数"), ("Minutes from epoch", "距历元分钟数"),

View File

@ -755,8 +755,8 @@ const TOOL_TABLE: &[ToolSpec] = &[
"Compute the two burns of a transfer between coplanar circular orbits.", "Compute the two burns of a transfer between coplanar circular orbits.",
), ),
description: Text::new( description: Text::new(
"返回将卫星在两个共面圆轨道之间转移的两次速度增量、两次点火之间的滑行时间以及总速度增量。正点火抬升轨道,负点火降低轨道。", "返回将卫星在两个共面圆轨道之间转移的两次速度增量、两次点火之间的滑行时间以及总速度增量。正点火抬升轨道,负点火降低轨道。\n\n每次点火的大小由齐奥科夫斯基方程给出:$\\Delta v = v_e \\ln(m_0 / m_f)$,其中 $v_e$ 是排气速度,$m_0$ 与 $m_f$ 分别是点火前后的质量。总速度增量是两次点火大小之和的绝对值。",
"Returns the two velocity changes that move a satellite between two circular coplanar orbits, the coast time between them, and the total velocity change. A positive burn raises the orbit and a negative one lowers it.", "Returns the two velocity changes that move a satellite between two circular coplanar orbits, the coast time between them, and the total velocity change. A positive burn raises the orbit and a negative one lowers it.\n\nThe size of each burn follows the Tsiolkovsky equation, $\\Delta v = v_e \\ln(m_0 / m_f)$, where $v_e$ is the exhaust velocity and $m_0$ and $m_f$ are the masses before and after the burn. The total is the absolute value of the sum of the two.",
), ),
category: Text::new("轨道", "Orbits"), category: Text::new("轨道", "Orbits"),
params: &[ params: &[
@ -1094,6 +1094,12 @@ pub fn registry_json(registry: &Registry) -> Value {
} }
/// Encodes one tool as JSON. /// Encodes one tool as JSON.
///
/// A description and a parameter help are written in Markdown, so they are
/// sent twice: as the source, which a reader of the JSON needs, and as HTML,
/// which is what the page shows. The HTML comes from the same renderer as
/// the concept documentation, so a formula written between dollar signs is
/// set the same way in both places.
fn tool_json(tool: &ToolSpec) -> Value { fn tool_json(tool: &ToolSpec) -> Value {
let params: Vec<Value> = tool.params.iter().map(param_json).collect(); let params: Vec<Value> = tool.params.iter().map(param_json).collect();
json_object! { json_object! {
@ -1101,12 +1107,21 @@ fn tool_json(tool: &ToolSpec) -> Value {
"name" => tool.name.json(), "name" => tool.name.json(),
"summary" => tool.summary.json(), "summary" => tool.summary.json(),
"description" => tool.description.json(), "description" => tool.description.json(),
"description_html" => rendered_text(&tool.description),
"category" => tool.category.json(), "category" => tool.category.json(),
"params" => params, "params" => params,
"api_items" => tool.api_items.iter().map(|name| Value::from(*name)).collect::<Vec<Value>>(), "api_items" => tool.api_items.iter().map(|name| Value::from(*name)).collect::<Vec<Value>>(),
} }
} }
/// Renders both languages of a piece of Markdown to HTML.
fn rendered_text(text: &Text) -> Value {
json_object! {
"zh" => crate::docs::markdown::render(text.zh),
"en" => crate::docs::markdown::render(text.en),
}
}
/// Encodes one parameter as JSON. /// Encodes one parameter as JSON.
fn param_json(param: &ParamSpec) -> Value { fn param_json(param: &ParamSpec) -> Value {
let (kind, options) = match &param.kind { let (kind, options) = match &param.kind {
@ -1140,6 +1155,7 @@ fn param_json(param: &ParamSpec) -> Value {
"name" => param.name, "name" => param.name,
"label" => param.label.json(), "label" => param.label.json(),
"help" => param.help.json(), "help" => param.help.json(),
"help_html" => rendered_text(&param.help),
"kind" => kind, "kind" => kind,
"required" => param.required, "required" => param.required,
"default" => param.default.map(Value::from).unwrap_or(Value::Null), "default" => param.default.map(Value::from).unwrap_or(Value::Null),

View File

@ -112,6 +112,14 @@ impl Table {
self.rows.push(row); self.rows.push(row);
} }
/// Returns the column at a position, if there is one.
///
/// A row is written by the handler, which may add or leave out a cell, so
/// a position is not guaranteed to name a column.
pub fn column(&self, index: usize) -> Option<&Column> {
self.columns.get(index)
}
/// Returns `true` when the table has no rows. /// Returns `true` when the table has no rows.
#[allow(dead_code)] #[allow(dead_code)]
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
@ -177,14 +185,48 @@ pub fn table_response(
"key" => column.key.clone(), "key" => column.key.clone(),
"label" => labels::label(&column.label).json(), "label" => labels::label(&column.label).json(),
"unit" => column.unit.clone(), "unit" => column.unit.clone(),
"unit_html" => unit_html(&column.unit),
"numeric" => column.numeric, "numeric" => column.numeric,
} }
}) })
.collect(); .collect();
// A text cell may hold a fixed phrase rather than data: the meaning of a
// field, for instance. Those are looked up so that a reader of either
// language sees prose in that language. A cell holding data was written
// by the reader rather than by this table, so the lookup gives it back
// unchanged in both languages, which is what it should show.
let rows: Vec<Value> = table let rows: Vec<Value> = table
.rows .rows
.iter() .iter()
.map(|row| Value::Array(row.clone())) .map(|row| {
Value::Array(
row.iter()
.enumerate()
.map(|(index, cell)| match cell {
Value::String(text)
if !table.column(index).is_some_and(|column| column.numeric) =>
{
let mut encoded = labels::label(text).json();
// A unit is written with a power in it, as in
// `rev/day^2`, and a unit is the one kind of text a
// table shows that is notation rather than prose.
// Only notation is rendered, so a sentence is never
// set as a formula and read as a row of variables.
if is_notation(text) {
if let Value::Object(fields) = &mut encoded {
fields.push((
"html".to_string(),
Value::from(crate::docs::mathml::render(text, false)),
));
}
}
encoded
}
other => other.clone(),
})
.collect(),
)
})
.collect(); .collect();
let scalar_values: Vec<Value> = scalars.iter().map(scalar_json).collect(); let scalar_values: Vec<Value> = scalars.iter().map(scalar_json).collect();
let series_values: Vec<Value> = series.iter().map(series_json).collect(); let series_values: Vec<Value> = series.iter().map(series_json).collect();
@ -195,10 +237,51 @@ pub fn table_response(
"scalars" => scalar_values, "scalars" => scalar_values,
"table" => json_object! { "columns" => columns, "rows" => rows }, "table" => json_object! { "columns" => columns, "rows" => rows },
"series" => series_values, "series" => series_values,
// A note is written in Markdown, so it is sent as the source and as
// HTML in both languages; a formula in a note is set the same way as
// one in a document.
"notes" => notes.iter().map(Text::json).collect::<Vec<Value>>(), "notes" => notes.iter().map(Text::json).collect::<Vec<Value>>(),
"notes_html" => notes.iter().map(note_html).collect::<Vec<Value>>(),
} }
} }
/// Renders one note to HTML in both languages.
fn note_html(note: &Text) -> Value {
json_object! {
"zh" => crate::docs::markdown::render(note.zh),
"en" => crate::docs::markdown::render(note.en),
}
}
/// Reports whether a piece of text is notation that should be set.
///
/// Only a unit qualifies. A unit is a single token carrying a power, such as
/// `rev/day^2`, and a power is what a reader needs to see raised.
///
/// Three things are deliberately excluded. A sentence such as `Mean motion`
/// is prose, and a formula sets its letters as variables, so a sentence read
/// that way stops being a sentence. An identifier such as `mean_motion` is a
/// field name, and it is already written the way the field is spelled; setting
/// it as a formula would split it into letters and turn the underscore into a
/// subscript. And a unit written with a space is prose with a caret in it,
/// which is not a unit.
fn is_notation(text: &str) -> bool {
text.contains('^') && !text.contains(' ')
}
/// Renders a unit as HTML, so that a power reads as a power.
///
/// A unit is notation rather than prose: `rev/day^2` is a name, and the only
/// mark-up it ever needs is the exponent. It is rendered by the same
/// mathematics renderer as a formula so that the two agree on how a power
/// looks, and so that a unit written with a subscript is set correctly too.
fn unit_html(unit: &str) -> Value {
if unit.is_empty() {
return Value::string(String::new());
}
Value::from(crate::docs::mathml::render(unit, false))
}
/// Returns a note written as a literal in the label table. /// Returns a note written as a literal in the label table.
/// ///
/// The note text is both the lookup key and the English form, so a note /// The note text is both the lookup key and the English form, so a note
@ -224,6 +307,7 @@ fn scalar_json(scalar: &Scalar) -> Value {
"label" => labels::label(&scalar.label).json(), "label" => labels::label(&scalar.label).json(),
"value" => scalar.value.clone(), "value" => scalar.value.clone(),
"unit" => scalar.unit.clone(), "unit" => scalar.unit.clone(),
"unit_html" => unit_html(&scalar.unit),
"monospace" => scalar.monospace, "monospace" => scalar.monospace,
} }
} }
@ -246,6 +330,63 @@ fn series_json(series: &Series) -> Value {
mod tests { mod tests {
use super::*; use super::*;
/// The first cell of the first row of a shaped response.
fn first_cell(value: &Value) -> Value {
let rows = value
.get("table")
.and_then(|table| table.get("rows"))
.expect("the response carries a table")
.as_array();
rows.first()
.and_then(|row| row.as_array().first())
.expect("the table carries a row")
.clone()
}
#[test]
fn a_text_cell_is_shown_in_either_language() {
// The meaning of a field is a fixed phrase, so it is looked up rather
// than written twice.
let mut table = Table::new(vec![Column::text("meaning", "Meaning")]);
table.push(vec![Value::from("Mean motion")]);
let value = table_response(
"kind",
"Title",
Vec::new(),
&table,
Vec::new(),
vec![labels::label("Mean motion")],
);
let cell = first_cell(&value);
assert_eq!(cell.get("zh").and_then(Value::as_str), Some("平均运动"));
assert_eq!(cell.get("en").and_then(Value::as_str), Some("Mean motion"));
}
#[test]
fn an_identifier_is_not_set_as_a_formula() {
// `mean_motion` is a field name. Set as a formula it would come out as
// separate letters with a subscript, which is not the field name.
let mut table = Table::new(vec![Column::text("field", "Field")]);
table.push(vec![Value::from("mean_motion")]);
let value = table_response("kind", "Title", Vec::new(), &table, Vec::new(), Vec::new());
let cell = first_cell(&value);
assert!(
cell.get("html").is_none(),
"an identifier should not be rendered: {cell:?}"
);
}
#[test]
fn a_unit_with_a_power_is_rendered() {
// `rev/day^2` is a unit, and the power is what a reader needs raised.
let mut table = Table::new(vec![Column::text("unit", "Unit")]);
table.push(vec![Value::from("rev/day^2")]);
let value = table_response("kind", "Title", Vec::new(), &table, Vec::new(), Vec::new());
let cell = first_cell(&value);
let html = cell.get("html").and_then(Value::as_str).unwrap_or_default();
assert!(html.contains("<msup>"), "the power was not raised: {html}");
}
#[test] #[test]
fn table_response_carries_the_shape() { fn table_response_carries_the_shape() {
let mut table = Table::new(vec![Column::number("a", "A", "km")]); let mut table = Table::new(vec![Column::number("a", "A", "km")]);