web: align the interface layout and translate the remaining captions

This commit is contained in:
dev 2026-10-01 08:46:46 +08:00
parent 3d716469d1
commit 839162dc3e
32 changed files with 479 additions and 70 deletions

View File

@ -1,5 +1,6 @@
--- ---
title: 工作区架构 title: 工作区架构
tags: 架构, 分层, crate
summary: apsis-rs 的分层结构、每层的 crate 以及依赖规则。 summary: apsis-rs 的分层结构、每层的 crate 以及依赖规则。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: 日历与约化儒略日 title: 日历与约化儒略日
tags: 时间, 日历, 儒略日, mjd
summary: 日编号系统、外推格里高利历,以及转换算法。 summary: 日编号系统、外推格里高利历,以及转换算法。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: 地球自转角 title: 地球自转角
tags: 参考系, 地球自转, iers, ut1
summary: 惯性系与地固系之间的夹角,以及给出该值的线性表达式。 summary: 惯性系与地固系之间的夹角,以及给出该值的线性表达式。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: 星历采样与插值 title: 星历采样与插值
tags: 星历, 插值, 采样, hermite
summary: 何时采样一次并多次查询轨迹,以及插值所付出的精度代价。 summary: 何时采样一次并多次查询轨迹,以及插值所付出的精度代价。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: WGS 84 椭球与大地坐标 title: WGS 84 椭球与大地坐标
tags: 大地测量, wgs84, 纬度, 椭球
summary: 纬度、经度与椭球高,以及到直角坐标的闭式转换。 summary: 纬度、经度与椭球高,以及到直角坐标的闭式转换。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: 开普勒轨道元素 title: 开普勒轨道元素
tags: 轨道, 开普勒, 元素, 近点角
summary: 六个经典轨道元素、二体解与三种近点角。 summary: 六个经典轨道元素、二体解与三种近点角。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: 闰秒 title: 闰秒
tags: 时间, utc, 闰秒, iers
summary: UTC 为何存在阶跃间断,以及内嵌闰秒表如何处理它们。 summary: UTC 为何存在阶跃间断,以及内嵌闰秒表如何处理它们。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: 概念总览 title: 概念总览
tags: 总览, 范围
summary: ApsisRS 计算内核的覆盖范围,以及本概念文档集的组织方式。 summary: ApsisRS 计算内核的覆盖范围,以及本概念文档集的组织方式。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: 轨道摄动 title: 轨道摄动
tags: 轨道, 摄动, 阻力, 扁率, 三体
summary: 使轨道偏离椭圆的各种力,以及各模型包含其中哪些。 summary: 使轨道偏离椭圆的各种力,以及各模型包含其中哪些。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: 传播器选择 title: 传播器选择
tags: 传播, 对比, sgp4, hpop
summary: 各种传播方法的对比,以及各自的开销与精度。 summary: 各种传播方法的对比,以及各自的开销与精度。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: 参考系 title: 参考系
tags: 参考系, 惯性系, 地固系, 大地坐标, 旋转
summary: 惯性系与地固系、在类型中携带参考系的位置表示,以及二者之间的旋转。 summary: 惯性系与地固系、在类型中携带参考系的位置表示,以及二者之间的旋转。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: SGP4 与 SDP4 传播 title: SGP4 与 SDP4 传播
tags: sgp4, sdp4, 传播, 两行元素集
summary: 两行元素集所拟合的解析传播器、其两个分支,以及坐标系与单位。 summary: 两行元素集所拟合的解析传播器、其两个分支,以及坐标系与单位。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: 标准与来源 title: 标准与来源
tags: 标准, 引用, 出处
summary: 各内核所遵循的公开标准与论文,以及引用方式。 summary: 各内核所遵循的公开标准与论文,以及引用方式。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: 时间尺度 title: 时间尺度
tags: 时间, utc, tai, tt, gps
summary: UTC、TAI、TT 与 GPS,以及为什么时刻只存储在单一尺度上。 summary: UTC、TAI、TT 与 GPS,以及为什么时刻只存储在单一尺度上。
--- ---

View File

@ -1,5 +1,6 @@
--- ---
title: 两行元素格式 title: 两行元素格式
tags: 两行元素集, 元素, 格式, 校验和
summary: 两行元素集的定宽列、校验和规则,以及两个容易误读的约定。 summary: 两行元素集的定宽列、校验和规则,以及两个容易误读的约定。
--- ---

View File

@ -276,6 +276,54 @@ sentence that contains 坐标, which a whole-run token would not. A single
Chinese character is left to the substring fallback rather than becoming a Chinese character is left to the substring fallback rather than becoming a
token, because a token of one character would match nearly every document. token, because a token of one character would match nearly every document.
## Interface layout
Four layout decisions keep the pages coherent at any window width.
A page is a **reading column**. Most pages are text, a card grid, or a form,
and all three read better in a centred column than across a whole wide
screen. A document is wider than the column because it carries a table of
contents beside the text; the width of the pair is capped so the two stay
together. A graph is the exception and uses the whole width, because a wide
screen should show more of the graph rather than more margin.
A grid of cards **stretches every card in a row** to the height of the
tallest. A card is therefore a column, its action is pushed to the bottom,
and its summary is clamped, so the actions of a row line up and one long
paragraph cannot stretch a card past its neighbours.
The sidebar is **a sticky element inside a frame**. The frame carries the
surface and the rule so the column is drawn for the whole page; the sidebar
itself is `sticky` and scrolls. Without the frame the column would end one
viewport down and read as cut off on a long page.
The graph canvas takes the **aspect of the layout space**, so the drawing
fills it instead of being letterboxed inside it. The force simulation
settles into a roughly round cluster while the canvas is wide, so the layout
is widened to the canvas shape before it is fitted; the change is a uniform
scale, which leaves relative distances readable.
## Language coverage
A user facing string falls into three classes, and each is handled
differently.
Interface chrome — navigation, headings, buttons, help text — is a
hand written dictionary in `frontend/src/lib/i18n.ts`, keyed by a stable
name. It covers the graph group captions and the interface item kinds, so a
Chinese reader sees a Chinese legend and Chinese group headings.
Authored content — concept documents, crate summaries, item documentation —
is a separate file per language, so the backend serves both and the reader
switches without another request. A document declares its own tags in each
language file under the same key, so the tag list follows the language too.
Derived text — a one line summary used in a card — is produced by the
backend from the document, and the renderer resolves the rustdoc forms a doc
comment uses. A shortcut reference such as `[`Vec3`]` is reduced to the item
name; without that, the brackets and backticks appear verbatim in the
summary.
## Testing ## Testing
Unit tests live beside the code they test and cover the parser, the codec, Unit tests live beside the code they test and cover the parser, the codec,

View File

@ -154,6 +154,7 @@
</svelte:head> </svelte:head>
<div class="app"> <div class="app">
<div class="sidebar-frame">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-brand"> <div class="sidebar-brand">
<a href="#/">APSIS</a> <a href="#/">APSIS</a>
@ -192,6 +193,7 @@
{/if} {/if}
</nav> </nav>
</aside> </aside>
</div>
<div class="main"> <div class="main">
<header class="topbar"> <header class="topbar">

View File

@ -170,16 +170,22 @@ hr {
min-height: 100vh; min-height: 100vh;
} }
.sidebar { /* The frame carries the surface and the rule, so the column is drawn for
width: var(--sidebar-width); the whole page. The sidebar itself is sticky and scrolls; without the
frame the column would end one viewport down and look cut off on a long
page. */
.sidebar-frame {
flex: 0 0 var(--sidebar-width); flex: 0 0 var(--sidebar-width);
background: var(--surface); background: var(--surface);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
padding: 1.25rem 0 2rem; }
.sidebar {
position: sticky; position: sticky;
top: 0; top: 0;
height: 100vh; height: 100vh;
overflow-y: auto; overflow-y: auto;
padding: 1.25rem 0 2rem;
} }
.sidebar-brand { .sidebar-brand {
@ -271,7 +277,7 @@ hr {
} }
.search-box { .search-box {
flex: 1 1 auto; flex: 0 1 auto;
max-width: 34rem; max-width: 34rem;
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@ -404,17 +410,61 @@ button.ghost:hover:not(:disabled) {
margin-top: 1rem; margin-top: 1rem;
} }
/* A filter field is a control, not a paragraph, so it is given a measure
rather than the whole width of the page. */
.filter-field {
max-width: 34rem;
}
.card h3 { .card h3 {
margin-top: 0; margin-top: 0;
} }
/* A grid of cards stretches every card in a row to the height of the
tallest. The card is a column so that its action sits at the bottom of
every card rather than floating at the height of the text above it. */
.grid.cards > .card {
display: flex;
flex-direction: column;
height: 100%;
}
.card-action {
margin-top: auto;
padding-top: 0.75rem;
align-self: flex-start;
}
/* A card is a column, so its button would otherwise stretch across the
whole card rather than sit under the text. */
.grid.cards > .card > button,
.grid.cards > .card > a {
align-self: flex-start;
}
/* The tag row follows the summary; only the action is pushed to the bottom,
so the actions of a row line up regardless of how many tags a card has. */
.card-tags {
padding-top: 0.15rem;
}
/* A summary is clamped so that one long paragraph does not stretch its card
far past the cards beside it. */
.card-clamp {
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.grid { .grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
} }
.grid.cards { .grid.cards {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
} }
.grid.metrics { .grid.metrics {
@ -438,8 +488,7 @@ button.ghost:hover:not(:disabled) {
.metric .label { .metric .label {
color: var(--text-faint); color: var(--text-faint);
font-size: 0.78rem; font-size: 0.78rem;
text-transform: uppercase; letter-spacing: 0.03em;
letter-spacing: 0.05em;
} }
.page-header { .page-header {
@ -463,6 +512,7 @@ button.ghost:hover:not(:disabled) {
.tag { .tag {
display: inline-block; display: inline-block;
white-space: nowrap;
background: var(--surface-raised); background: var(--surface-raised);
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text-muted); color: var(--text-muted);
@ -474,6 +524,7 @@ button.ghost:hover:not(:disabled) {
.badge { .badge {
display: inline-block; display: inline-block;
white-space: nowrap;
border-radius: var(--radius-small); border-radius: var(--radius-small);
padding: 0.05rem 0.4rem; padding: 0.05rem 0.4rem;
font-size: 0.72rem; font-size: 0.72rem;
@ -726,7 +777,11 @@ tbody tr:hover {
.graph-canvas { .graph-canvas {
width: 100%; width: 100%;
height: 34rem; /* The aspect of the layout space, so the drawing fills the canvas
instead of being letterboxed inside it. */
aspect-ratio: 2 / 1;
min-height: 20rem;
max-height: 46rem;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
@ -820,6 +875,19 @@ tbody tr:hover {
flex-wrap: wrap; flex-wrap: wrap;
} }
/* The items of one crate, two to a row, so that a crate with dozens of
items stays a reasonable height. */
.item-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(28rem, 1fr));
gap: 0.6rem;
align-items: start;
}
.item-grid .api-item {
margin-bottom: 0;
}
.api-item .name { .api-item .name {
font-family: var(--mono); font-family: var(--mono);
font-size: 0.95rem; font-size: 0.95rem;
@ -957,6 +1025,35 @@ tbody tr:hover {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* A tool in the tool index. The card is a link, so the whole area is the
target and the hover is the affordance. */
.tool-card {
color: var(--text);
}
.tool-card:hover {
border-color: var(--accent-strong);
background: var(--surface-raised);
text-decoration: none;
}
.tool-card h4 {
margin: 0 0 0.35rem;
font-size: 0.98rem;
color: var(--accent);
}
.tool-card p {
margin: 0;
color: var(--text-muted);
font-size: 0.86rem;
}
.tool-card.selected {
border-color: var(--accent);
background: var(--accent-soft);
}
/* A row of links that wrap, used for the item filter results. */ /* A row of links that wrap, used for the item filter results. */
.chip-row { .chip-row {
display: flex; display: flex;
@ -1061,6 +1158,18 @@ tbody tr:hover {
/* Responsive */ /* Responsive */
@media (max-width: 700px) {
/* The counters are a summary, not navigation, so they are the first
thing to go when the bar is tight. */
.topbar .badge {
display: none;
}
.topbar {
padding: 0.6rem 1rem;
}
}
@media (max-width: 960px) { @media (max-width: 960px) {
.app { .app {
flex-direction: column; flex-direction: column;

View File

@ -37,9 +37,14 @@
/** The radius of a node marker. */ /** The radius of a node marker. */
const NODE_RADIUS = 9; const NODE_RADIUS = 9;
/** Width and height of the layout space. */ /**
const WIDTH = 1200; * Width and height of the layout space.
const HEIGHT = 760; *
* The canvas takes this aspect, so the drawing fills it rather than being
* letterboxed inside it.
*/
const WIDTH = 1400;
const HEIGHT = 700;
let placements = $state<Map<string, Placement>>(new Map()); let placements = $state<Map<string, Placement>>(new Map());
let span = $state(1); let span = $state(1);
@ -120,14 +125,84 @@
placement.y = Math.min(HEIGHT - 60, Math.max(60, placement.y)); placement.y = Math.min(HEIGHT - 60, Math.max(60, placement.y));
} }
} }
return result; return spreadToCanvas(result);
}
/**
* Widens a layout until it has the shape of the canvas.
*
* The simulation settles into a roughly round cluster, while the canvas is
* wide. Left alone the fit then scales by the short axis and leaves a large
* empty margin on the long one. Stretching along the long axis to match the
* canvas shape uses the whole area; the distortion is a uniform scale, so
* relative distances and the layout stay readable.
*/
function spreadToCanvas(points: Map<string, Placement>): Map<string, Placement> {
const values = [...points.values()];
if (values.length < 2) return points;
let minX = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const point of values) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
const width = maxX - minX;
const height = maxY - minY;
if (width < 1 || height < 1) return points;
const wanted = WIDTH / HEIGHT;
const current = width / height;
// Only widen a narrow layout; a layout that is already wider than the
// canvas is left alone, since stretching further would overflow it.
if (current >= wanted * 0.9) return points;
const scale = (wanted * height) / width;
if (scale > 3) return points;
const centreX = (minX + maxX) / 2;
for (const point of values) {
point.x = centreX + (point.x - centreX) * scale;
}
return points;
}
/**
* Scales and centres the view so that every node is visible with a margin.
*
* The layout occupies a fixed design space, which the force simulation
* fills only in its centre. Fitting the view to the nodes removes the
* empty margin the reader would otherwise have to pan across.
*/
function fitToNodes() {
if (placements.size === 0) {
span = 1;
offsetX = 0;
offsetY = 0;
return;
}
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const placement of placements.values()) {
minX = Math.min(minX, placement.x);
maxX = Math.max(maxX, placement.x);
minY = Math.min(minY, placement.y);
maxY = Math.max(maxY, placement.y);
}
const margin = 70;
const width = Math.max(1, maxX - minX + margin * 2);
const height = Math.max(1, maxY - minY + margin * 2);
const scale = Math.min(WIDTH / width, HEIGHT / height);
span = Math.max(0.3, Math.min(2.5, scale));
offsetX = (WIDTH - (maxX - minX) * span) / 2 - minX * span;
offsetY = (HEIGHT - (maxY - minY) * span) / 2 - minY * span;
} }
$effect(() => { $effect(() => {
placements = layout(graph); placements = layout(graph);
span = 1; fitToNodes();
offsetX = 0;
offsetY = 0;
}); });
/** The transform of the layout viewport. */ /** The transform of the layout viewport. */
@ -166,16 +241,14 @@
span = next; span = next;
} }
/** Resets the viewport. */ /** Returns the viewport to the fitted view. */
function reset() { function reset() {
span = 1; fitToNodes();
offsetX = 0;
offsetY = 0;
} }
</script> </script>
<div class="graph-controls"> <div class="graph-controls">
<button class="ghost" type="button" onclick={reset}>{tr('graph.reset', lang)}</button> <button class="ghost" type="button" onclick={reset}>{tr('graph.fit', lang)}</button>
<span class="badge">{graph.nodes.length} {tr('graph.nodes', lang)}</span> <span class="badge">{graph.nodes.length} {tr('graph.nodes', lang)}</span>
<span class="badge">{graph.edges.length} {tr('graph.edges', lang)}</span> <span class="badge">{graph.edges.length} {tr('graph.edges', lang)}</span>
<span class="badge">{tr('graph.zoom', lang)} {span.toFixed(2)}×</span> <span class="badge">{tr('graph.zoom', lang)} {span.toFixed(2)}×</span>

View File

@ -27,6 +27,12 @@ export interface Text {
en: string; en: string;
} }
/** A list of strings carried in both languages. */
export interface BilingualList {
zh: string[];
en: string[];
}
/** Returns `true` when the value is a supported language tag. */ /** Returns `true` when the value is a supported language tag. */
export function isLanguage(value: unknown): value is Language { export function isLanguage(value: unknown): value is Language {
return value === 'zh' || value === 'en'; return value === 'zh' || value === 'en';
@ -77,6 +83,19 @@ export function pick(value: Text | null | undefined, target?: Language): string
return value.en || value.zh; return value.en || value.zh;
} }
/** Picks a list from a bilingual value that may be empty in one language. */
export function pickList(
value: BilingualList | null | undefined,
target?: Language,
): string[] {
if (!value) return [];
const selected = target ?? get(language);
// A list can be empty in one language and filled in the other, so the
// length is checked rather than the truthiness of the array.
if (selected === 'zh') return value.zh?.length ? value.zh : (value.en ?? []);
return value.en?.length ? value.en : (value.zh ?? []);
}
/** The interface strings, keyed by a stable name. */ /** The interface strings, keyed by a stable name. */
const STRINGS: Record<string, Text> = { const STRINGS: Record<string, Text> = {
'app.title': { zh: 'APSIS', en: 'APSIS' }, 'app.title': { zh: 'APSIS', en: 'APSIS' },
@ -172,7 +191,7 @@ const STRINGS: Record<string, Text> = {
'graph.edges': { zh: '边', en: 'edges' }, 'graph.edges': { zh: '边', en: 'edges' },
'graph.zoom': { zh: '缩放', en: 'zoom' }, 'graph.zoom': { zh: '缩放', en: 'zoom' },
'graph.reset': { zh: '复位视图', en: 'Reset view' }, 'graph.reset': { zh: '复位视图', en: 'Reset view' },
'graph.fit': { zh: '适配', en: 'Fit' }, 'graph.fit': { zh: '适应窗口', en: 'Fit to view' },
'graph.openDocument': { zh: '打开文档', en: 'Open the document' }, 'graph.openDocument': { zh: '打开文档', en: 'Open the document' },
'graph.interface': { zh: '接口:', en: 'Interface:' }, 'graph.interface': { zh: '接口:', en: 'Interface:' },
'graph.crateNote': { 'graph.crateNote': {
@ -233,6 +252,50 @@ const STRINGS: Record<string, Text> = {
'common.notFound': { zh: '不存在页面', en: 'There is no page at' }, 'common.notFound': { zh: '不存在页面', en: 'There is no page at' },
'common.goHome': { zh: '前往总览', en: 'Go to the overview' }, 'common.goHome': { zh: '前往总览', en: 'Go to the overview' },
'common.language': { zh: '语言', en: 'Language' }, 'common.language': { zh: '语言', en: 'Language' },
// Group captions of the knowledge graph and the flow graphs. The keys are
// the group identifiers used by the content data.
'group.overview': { zh: '总览', en: 'Overview' },
'group.time': { zh: '时间', en: 'Time' },
'group.geometry': { zh: '坐标与几何', en: 'Geometry' },
'group.elements': { zh: '元素集', en: 'Elements' },
'group.propagation': { zh: '轨道传播', en: 'Propagation' },
'group.methods': { zh: '方法对比', en: 'Methods' },
'group.foundation': { zh: '基础层', en: 'Foundation' },
'group.physics': { zh: '物理层', en: 'Physics' },
'group.orbit': { zh: '轨道层', en: 'Orbit' },
'group.rf': { zh: '射频层', en: 'Radio' },
'group.network': { zh: '网络层', en: 'Network' },
'group.ops': { zh: '运行层', en: 'Operations' },
'group.sim': { zh: '仿真层', en: 'Simulation' },
'group.input': { zh: '输入', en: 'Input' },
'group.parse': { zh: '解析', en: 'Parse' },
'group.prepare': { zh: '准备', en: 'Prepare' },
'group.branch': { zh: '分支', en: 'Branch' },
'group.propagate': { zh: '传播', en: 'Propagate' },
'group.verify': { zh: '校验', en: 'Verify' },
'group.fields': { zh: '字段', en: 'Fields' },
'group.crosscheck': { zh: '交叉检查', en: 'Cross-check' },
'group.error': { zh: '错误', en: 'Error' },
'group.output': { zh: '输出', en: 'Output' },
'group.validate': { zh: '校验', en: 'Validate' },
'group.convert': { zh: '换算', en: 'Convert' },
'group.store': { zh: '存储', en: 'Store' },
'group.frames': { zh: '参考系', en: 'Frames' },
'group.rotate': { zh: '旋转', en: 'Rotate' },
'group.geodetic': { zh: '大地坐标', en: 'Geodetic' },
'group.general': { zh: '其他', en: 'General' },
// Item kinds of the interface documentation.
'kind.struct': { zh: '结构体', en: 'struct' },
'kind.enum': { zh: '枚举', en: 'enum' },
'kind.trait': { zh: 'trait', en: 'trait' },
'kind.fn': { zh: '函数', en: 'fn' },
'kind.const': { zh: '常量', en: 'const' },
'kind.type': { zh: '类型别名', en: 'type' },
'api.itemCount': { zh: '个接口项', en: 'items' },
'tools.choose': { zh: '共 {count} 个工具,按类别分组。', en: '{count} tools, grouped by category.' },
}; };
/** Returns an interface string in the given language. */ /** Returns an interface string in the given language. */
@ -249,4 +312,37 @@ export function t(key: string): string {
return get(language) === 'zh' ? entry.zh : entry.en; return get(language) === 'zh' ? entry.zh : entry.en;
} }
/**
* Returns the caption of a graph group.
*
* A group that is not in the dictionary is shown as written, which keeps a
* new group in the content readable rather than blank.
*/
export function groupLabel(group: string, target?: Language): string {
const entry = STRINGS[`group.${group}`];
if (!entry) return group;
return pick(entry, target);
}
/**
* Returns the caption of an interface item kind.
*/
export function kindLabel(kind: string, target?: Language): string {
const entry = STRINGS[`kind.${kind}`];
if (!entry) return kind;
return pick(entry, target);
}
/**
* The headings that introduce a group of interface items, in the plural.
*/
export const KIND_GROUPS: Record<string, Text> = {
struct: { zh: '结构体', en: 'Structs' },
enum: { zh: '枚举', en: 'Enums' },
trait: { zh: 'trait', en: 'Traits' },
fn: { zh: '函数', en: 'Functions' },
const: { zh: '常量', en: 'Constants' },
type: { zh: '类型别名', en: 'Type aliases' },
};
export default t; export default t;

View File

@ -14,6 +14,12 @@ export interface Bilingual {
en: string; en: string;
} }
/** A list of strings carried in both languages. */
export interface BilingualList {
zh: string[];
en: string[];
}
/** Service health. */ /** Service health. */
export interface Health { export interface Health {
status: string; status: string;
@ -42,10 +48,11 @@ export interface DocumentForm {
export interface Document { export interface Document {
id: string; id: string;
chapter: string; chapter: string;
tags: string[];
prerequisites: string[]; prerequisites: string[];
references: string[]; references: string[];
forms: DocumentForm[]; forms: DocumentForm[];
/** Tags in both languages. */
tags: BilingualList;
} }
/** The document listing, grouped by chapter. */ /** The document listing, grouped by chapter. */
@ -55,7 +62,7 @@ export interface DocumentIndex {
id: string; id: string;
title: Bilingual; title: Bilingual;
summary: Bilingual; summary: Bilingual;
documents: { id: string; title: Bilingual; summary: Bilingual; tags: string[] }[]; documents: { id: string; title: Bilingual; summary: Bilingual; tags: BilingualList }[];
}[]; }[];
} }
@ -66,6 +73,8 @@ export interface SearchHit {
title: string; title: string;
summary: string; summary: string;
chapter: string; chapter: string;
/** The chapter title in both languages, so a result is readable. */
chapter_title: Bilingual;
score: number; score: number;
} }

View File

@ -16,6 +16,7 @@
import { getJson } from '../lib/api'; import { getJson } from '../lib/api';
import { navigate } from '../lib/router'; import { navigate } from '../lib/router';
import { language, pick, tr } from '../lib/i18n'; import { language, pick, tr } from '../lib/i18n';
import { KIND_GROUPS } from '../lib/i18n';
import Signature from '../lib/Signature.svelte'; import Signature from '../lib/Signature.svelte';
interface Props { interface Props {
@ -23,6 +24,8 @@
id: string | null; id: string | null;
} }
type Language = import('../lib/i18n').Language;
let { index, id }: Props = $props(); let { index, id }: Props = $props();
/** The current language, read so that the text re-renders on a switch. */ /** The current language, read so that the text re-renders on a switch. */
@ -96,6 +99,17 @@
})).filter((group) => group.items.length > 0); })).filter((group) => group.items.length > 0);
}); });
/**
* Returns the heading of a group of interface items.
*
* The heading names the kind in the reader's language, in the plural,
* because it introduces a list.
*/
function groupHeading(kind: string, language: Language): string {
const names = KIND_GROUPS[kind];
return names ? pick(names, language) : kind;
}
/** Opens an item route. */ /** Opens an item route. */
function openItem(name: string) { function openItem(name: string) {
navigate(`/api/${name}`); navigate(`/api/${name}`);
@ -119,20 +133,6 @@
} }
} }
/**
* Returns the first sentence of a crate summary.
*
* A crate summary is the module documentation, which is several
* paragraphs. The index shows one sentence per crate so that the grid
* stays readable; the full text is on the crate page.
*/
function firstSentence(summary: string): string {
const text = summary.replace(/\s+/g, ' ').trim();
if (text === '') return '';
const end = text.search(/[.!?](\s|$)/);
if (end === -1) return text.length > 160 ? `${text.slice(0, 157)}...` : text;
return text.slice(0, end + 1);
}
</script> </script>
<div class="page-header"> <div class="page-header">
@ -155,9 +155,10 @@
{#each groups as group} {#each groups as group}
<h3 id={`kind-${group.kind}`}> <h3 id={`kind-${group.kind}`}>
{group.kind === 'fn' ? 'functions' : `${group.kind}s`} {groupHeading(group.kind, lang)}
<span class="badge">{group.items.length}</span> <span class="badge">{group.items.length}</span>
</h3> </h3>
<div class="item-grid">
{#each group.items as candidate (candidate.name + candidate.signature)} {#each group.items as candidate (candidate.name + candidate.signature)}
<article class="api-item"> <article class="api-item">
<header> <header>
@ -186,6 +187,7 @@
{/if} {/if}
</article> </article>
{/each} {/each}
</div>
{/each} {/each}
{:else if item} {:else if item}
<!-- One item on its own. --> <!-- One item on its own. -->
@ -230,6 +232,7 @@
<section class="card"> <section class="card">
<h3 style="margin-top: 0">{tr('api.find', lang)}</h3> <h3 style="margin-top: 0">{tr('api.find', lang)}</h3>
<input <input
class="filter-field"
type="search" type="search"
placeholder={`${tr('api.filter', lang)} (${index.item_count})`} placeholder={`${tr('api.filter', lang)} (${index.item_count})`}
bind:value={filter} bind:value={filter}
@ -256,9 +259,9 @@
<header> <header>
<span class="name">{candidate.name}</span> <span class="name">{candidate.name}</span>
<span class="badge">{candidate.layer}</span> <span class="badge">{candidate.layer}</span>
<span class="badge">{candidate.items}</span> <span class="badge">{candidate.items} {tr('api.itemCount', lang)}</span>
</header> </header>
<p>{firstSentence(pick(candidate.summary.plain, lang))}</p> <p class="card-clamp">{pick(candidate.summary.plain, lang)}</p>
</a> </a>
{/each} {/each}
</div> </div>

View File

@ -7,7 +7,7 @@
*/ */
import type { DocumentIndex } from '../lib/types'; import type { DocumentIndex } from '../lib/types';
import { navigate } from '../lib/router'; import { navigate } from '../lib/router';
import { language, pick, tr } from '../lib/i18n'; import { language, pick, pickList, tr } from '../lib/i18n';
interface Props { interface Props {
index: DocumentIndex | null; index: DocumentIndex | null;
@ -37,18 +37,17 @@
{#each chapter.documents as document} {#each chapter.documents as document}
<article class="card"> <article class="card">
<h3><a href={`#/docs/${document.id}`}>{pick(document.title, lang)}</a></h3> <h3><a href={`#/docs/${document.id}`}>{pick(document.title, lang)}</a></h3>
<p style="color: var(--text-muted); margin: 0.4rem 0 0.6rem"> <p class="card-clamp" style="color: var(--text-muted); margin: 0.4rem 0 0.6rem">
{pick(document.summary, lang)} {pick(document.summary, lang)}
</p> </p>
<div> <div class="card-tags">
{#each document.tags as tag} {#each pickList(document.tags, lang) as tag}
<span class="tag">{tag}</span> <span class="tag">{tag}</span>
{/each} {/each}
</div> </div>
<button <button
class="ghost" class="ghost card-action"
type="button" type="button"
style="margin-top: 0.75rem;"
onclick={() => navigate(`/docs/${document.id}`)}>{tr('docs.read', lang)}</button> onclick={() => navigate(`/docs/${document.id}`)}>{tr('docs.read', lang)}</button>
</article> </article>
{/each} {/each}

View File

@ -13,7 +13,7 @@
*/ */
import type { Document, DocumentForm } from '../lib/types'; import type { Document, DocumentForm } from '../lib/types';
import { navigate } from '../lib/router'; import { navigate } from '../lib/router';
import { language, setLanguage, tr, type Language } from '../lib/i18n'; import { language, pickList, setLanguage, tr, type Language } from '../lib/i18n';
interface Props { interface Props {
document: Document | null; document: Document | null;
@ -101,7 +101,7 @@
<p style="color: var(--text-muted); font-size: 1.05rem;">{form.summary}</p> <p style="color: var(--text-muted); font-size: 1.05rem;">{form.summary}</p>
{/if} {/if}
<div> <div>
{#each document.tags as tag} {#each pickList(document.tags, lang) as tag}
<span class="tag">{tag}</span> <span class="tag">{tag}</span>
{/each} {/each}
{#if otherLanguage} {#if otherLanguage}

View File

@ -10,7 +10,7 @@
import type { FlowIndex, Graph, GraphNode } from '../lib/types'; import type { FlowIndex, Graph, GraphNode } from '../lib/types';
import { getJson } from '../lib/api'; import { getJson } from '../lib/api';
import { groupColor } from '../lib/format'; import { groupColor } from '../lib/format';
import { language, pick, tr } from '../lib/i18n'; import { groupLabel, language, pick, tr } from '../lib/i18n';
import GraphView from '../lib/GraphView.svelte'; import GraphView from '../lib/GraphView.svelte';
interface Props { interface Props {
@ -96,7 +96,7 @@
<div class="legend"> <div class="legend">
{#each graph.groups as group} {#each graph.groups as group}
<span><i style={`background:${groupColor(group)}`}></i>{group}</span> <span><i style={`background:${groupColor(group)}`}></i>{groupLabel(group, lang)}</span>
{/each} {/each}
</div> </div>

View File

@ -10,7 +10,7 @@
import { getJson } from '../lib/api'; import { getJson } from '../lib/api';
import { navigate } from '../lib/router'; import { navigate } from '../lib/router';
import { groupColor } from '../lib/format'; import { groupColor } from '../lib/format';
import { language, pick, tr } from '../lib/i18n'; import { groupLabel, language, pick, tr } from '../lib/i18n';
import GraphView from '../lib/GraphView.svelte'; import GraphView from '../lib/GraphView.svelte';
interface Props { interface Props {
@ -92,7 +92,7 @@
<div class="legend"> <div class="legend">
{#each graph.groups as group} {#each graph.groups as group}
<span><i style={`background:${groupColor(group)}`}></i>{group}</span> <span><i style={`background:${groupColor(group)}`}></i>{groupLabel(group, lang)}</span>
{/each} {/each}
</div> </div>
@ -102,7 +102,7 @@
<h3 style="margin: 0;">{pick(selectedNode.label, lang)}</h3> <h3 style="margin: 0;">{pick(selectedNode.label, lang)}</h3>
<span class="badge">{selectedNode.kind}</span> <span class="badge">{selectedNode.kind}</span>
<span class="badge" style={`color:${groupColor(selectedNode.group)}`} <span class="badge" style={`color:${groupColor(selectedNode.group)}`}
>{selectedNode.group}</span> >{groupLabel(selectedNode.group, lang)}</span>
</header> </header>
<p style="color: var(--text-muted);">{pick(selectedNode.summary, lang)}</p> <p style="color: var(--text-muted);">{pick(selectedNode.summary, lang)}</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;"> <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">

View File

@ -7,7 +7,7 @@
* that a reader can tell why it was returned. * that a reader can tell why it was returned.
*/ */
import type { SearchResponse } from '../lib/types'; import type { SearchResponse } from '../lib/types';
import { language, tr } from '../lib/i18n'; import { language, pick, tr } from '../lib/i18n';
interface Props { interface Props {
response: SearchResponse | null; response: SearchResponse | null;
@ -62,8 +62,7 @@
<a class="result-title" href={`#/docs/${hit.id}`}>{hit.title}</a> <a class="result-title" href={`#/docs/${hit.id}`}>{hit.title}</a>
<p class="result-summary">{excerpt(hit.summary, query)}</p> <p class="result-summary">{excerpt(hit.summary, query)}</p>
<div class="result-meta"> <div class="result-meta">
<span class="badge">{hit.chapter}</span> <span class="badge">{pick(hit.chapter_title, lang)}</span>
<span class="badge">{hit.language}</span>
<span class="badge">{tr('search.score', lang)} {hit.score.toFixed(2)}</span> <span class="badge">{tr('search.score', lang)} {hit.score.toFixed(2)}</span>
</div> </div>
</li> </li>

View File

@ -52,9 +52,13 @@
const currentRegistry = registry; const currentRegistry = registry;
const currentId = id; const currentId = id;
if (!currentRegistry) return; if (!currentRegistry) return;
const found = currentId // With no tool named in the route the first one is shown, so the page
? (currentRegistry.tools.find((candidate) => candidate.id === currentId) ?? null) // opens on a working form rather than an invitation to pick.
: null; const wanted =
(currentId
? currentRegistry.tools.find((candidate) => candidate.id === currentId)
: undefined) ?? currentRegistry.tools[0];
const found = wanted ?? null;
tool = found; tool = found;
result = null; result = null;
error = null; error = null;
@ -125,18 +129,17 @@
<p class="empty">{tr('tools.loading', lang)}</p> <p class="empty">{tr('tools.loading', lang)}</p>
{:else} {:else}
{#each categories as group} {#each categories as group}
<section style="margin-bottom: 1.25rem;"> <section style="margin-bottom: 2rem;">
<h3 style="margin-bottom: 0.5rem;">{pick(group.category, lang)}</h3> <h3 style="margin-bottom: 0.75rem;">{pick(group.category, lang)}</h3>
<div style="display: flex; flex-wrap: wrap; gap: 0.4rem;"> <div class="grid cards">
{#each group.tools as candidate} {#each group.tools as candidate}
<a <a
class="card tool-card"
class:selected={id === candidate.id}
href={`#/tools/${candidate.id}`} href={`#/tools/${candidate.id}`}
class="badge"
style={id === candidate.id
? 'border-color: var(--accent); color: var(--accent); padding: 0.3rem 0.7rem;'
: 'padding: 0.3rem 0.7rem;'}
> >
{pick(candidate.name, lang)} <h4>{pick(candidate.name, lang)}</h4>
<p>{pick(candidate.summary, lang)}</p>
</a> </a>
{/each} {/each}
</div> </div>

View File

@ -149,6 +149,7 @@ impl Router {
"summary" => hit.summary.clone(), "summary" => hit.summary.clone(),
"language" => hit.language.tag(), "language" => hit.language.tag(),
"chapter" => hit.chapter.clone(), "chapter" => hit.chapter.clone(),
"chapter_title" => chapter_title(&self.state, &hit.chapter),
"score" => hit.score, "score" => hit.score,
} }
}) })
@ -197,7 +198,7 @@ impl Router {
let body = json_object! { let body = json_object! {
"id" => document.id.clone(), "id" => document.id.clone(),
"chapter" => document.chapter.clone(), "chapter" => document.chapter.clone(),
"tags" => document.tags.iter().map(|tag| Value::from(tag.as_str())).collect::<Vec<Value>>(), "tags" => tag_lists(document),
"prerequisites" => document.prerequisites.iter().map(|id| Value::from(id.as_str())).collect::<Vec<Value>>(), "prerequisites" => document.prerequisites.iter().map(|id| Value::from(id.as_str())).collect::<Vec<Value>>(),
"references" => document.references().iter().map(|id| Value::from(id.as_str())).collect::<Vec<Value>>(), "references" => document.references().iter().map(|id| Value::from(id.as_str())).collect::<Vec<Value>>(),
"forms" => forms, "forms" => forms,
@ -424,7 +425,7 @@ fn document_index(state: &AppState) -> Value {
"id" => document.id.clone(), "id" => document.id.clone(),
"title" => bilingual("title", document), "title" => bilingual("title", document),
"summary" => bilingual("summary", document), "summary" => bilingual("summary", document),
"tags" => document.tags.iter().map(|tag| Value::from(tag.as_str())).collect::<Vec<Value>>(), "tags" => tag_lists(document),
} }
}) })
.collect(); .collect();
@ -542,6 +543,22 @@ fn flow_list_json(flows: &[Graph]) -> Value {
json_object! { "flows" => items } json_object! { "flows" => items }
} }
/// Encodes the tag lists of a document, one per language.
fn tag_lists(document: &crate::docs::Document) -> Value {
json_object! {
"en" => document.tags.iter().map(|tag| Value::from(tag.as_str())).collect::<Vec<Value>>(),
"zh" => document.tags_zh.iter().map(|tag| Value::from(tag.as_str())).collect::<Vec<Value>>(),
}
}
/// Encodes the title of a chapter, in both languages.
fn chapter_title(state: &AppState, chapter_id: &str) -> Value {
let Some(chapter) = state.docs.chapters().iter().find(|c| c.id == chapter_id) else {
return Value::string(chapter_id);
};
optional_bilingual(&chapter.title_zh, &chapter.title)
}
/// Encodes a graph as JSON. /// Encodes a graph as JSON.
/// ///
/// A display label and a summary are carried in both languages when the /// A display label and a summary are carried in both languages when the

View File

@ -73,8 +73,10 @@ pub struct Document {
pub id: String, pub id: String,
/// Chapter identifier the document belongs to. /// Chapter identifier the document belongs to.
pub chapter: String, pub chapter: String,
/// Free form tags. /// Free form tags in the language of the English form.
pub tags: Vec<String>, pub tags: Vec<String>,
/// The same tags in Chinese, when the document declares them.
pub tags_zh: Vec<String>,
/// Identifier of the document this one builds on, when declared. /// Identifier of the document this one builds on, when declared.
pub prerequisites: Vec<String>, pub prerequisites: Vec<String>,
/// The English form. Every document has one, and it is the fallback /// The English form. Every document has one, and it is the fallback
@ -105,6 +107,7 @@ impl Document {
id: id.to_string(), id: id.to_string(),
chapter: chapter_of(text), chapter: chapter_of(text),
tags: list_of(text, "tags"), tags: list_of(text, "tags"),
tags_zh: Vec::new(),
prerequisites: list_of(text, "prerequisites"), prerequisites: list_of(text, "prerequisites"),
english: variant, english: variant,
chinese: None, chinese: None,
@ -218,6 +221,14 @@ fn strip_leading_title(body: &str, title: &str) -> String {
body.to_string() body.to_string()
} }
/// Returns the tag list a document header declares.
///
/// Each language file declares its own tags under the same key, so the
/// loader reads them from the translation rather than from the original.
pub fn tags_of(text: &str) -> Vec<String> {
list_of(text, "tags")
}
/// Returns the value of a metadata key, trimmed. /// Returns the value of a metadata key, trimmed.
fn field_of(header: &str, key: &str) -> Option<String> { fn field_of(header: &str, key: &str) -> Option<String> {
for line in header.lines() { for line in header.lines() {

View File

@ -683,6 +683,18 @@ fn strip_inline(text: &str) -> String {
continue; continue;
} }
} }
if character == '[' && characters.get(index + 1) == Some(&'`') {
// A rustdoc shortcut reference, `[`Epoch`]`, is a reference to
// another item. The plain text keeps the item name so that a
// summary still names what it is about, and the brackets never
// reach the reader.
if let Some((label, end)) = parse_shortcut_link(&characters, index) {
let name = label.rsplit("::").next().unwrap_or(&label);
output.push_str(name.trim());
index = end;
continue;
}
}
if character == '[' { if character == '[' {
if let Some((label, _, end)) = parse_link(&characters, index) { if let Some((label, _, end)) = parse_link(&characters, index) {
output.push_str(&label); output.push_str(&label);
@ -809,4 +821,15 @@ mod tests {
assert!(!text.contains("[[")); assert!(!text.contains("[["));
assert!(!text.contains('`')); assert!(!text.contains('`'));
} }
#[test]
fn plain_text_resolves_a_shortcut_reference() {
// A rustdoc shortcut keeps the item name and loses the brackets,
// which otherwise appear verbatim in a one line summary.
let text = to_plain_text("See [`Vec3`] and [`Epoch::mjd`](crate::Epoch::mjd).\n");
assert!(text.contains("Vec3"));
assert!(text.contains("mjd"));
assert!(!text.contains('['), "{text}");
assert!(!text.contains('`'), "{text}");
}
} }

View File

@ -73,8 +73,10 @@ pub fn load_documents() -> Vec<Document> {
let Some(translation) = DocumentVariant::parse(text) else { let Some(translation) = DocumentVariant::parse(text) else {
continue; continue;
}; };
let tags_zh = crate::docs::document::tags_of(text);
if let Some(document) = documents.get_mut(stem) { if let Some(document) = documents.get_mut(stem) {
document.chinese = Some(translation); document.chinese = Some(translation);
document.tags_zh = tags_zh;
} }
} }