web: align the interface layout and translate the remaining captions
This commit is contained in:
parent
3d716469d1
commit
839162dc3e
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: 工作区架构
|
||||
tags: 架构, 分层, crate
|
||||
summary: apsis-rs 的分层结构、每层的 crate 以及依赖规则。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: 日历与约化儒略日
|
||||
tags: 时间, 日历, 儒略日, mjd
|
||||
summary: 日编号系统、外推格里高利历,以及转换算法。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: 地球自转角
|
||||
tags: 参考系, 地球自转, iers, ut1
|
||||
summary: 惯性系与地固系之间的夹角,以及给出该值的线性表达式。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: 星历采样与插值
|
||||
tags: 星历, 插值, 采样, hermite
|
||||
summary: 何时采样一次并多次查询轨迹,以及插值所付出的精度代价。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: WGS 84 椭球与大地坐标
|
||||
tags: 大地测量, wgs84, 纬度, 椭球
|
||||
summary: 纬度、经度与椭球高,以及到直角坐标的闭式转换。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: 开普勒轨道元素
|
||||
tags: 轨道, 开普勒, 元素, 近点角
|
||||
summary: 六个经典轨道元素、二体解与三种近点角。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: 闰秒
|
||||
tags: 时间, utc, 闰秒, iers
|
||||
summary: UTC 为何存在阶跃间断,以及内嵌闰秒表如何处理它们。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: 概念总览
|
||||
tags: 总览, 范围
|
||||
summary: ApsisRS 计算内核的覆盖范围,以及本概念文档集的组织方式。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: 轨道摄动
|
||||
tags: 轨道, 摄动, 阻力, 扁率, 三体
|
||||
summary: 使轨道偏离椭圆的各种力,以及各模型包含其中哪些。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: 传播器选择
|
||||
tags: 传播, 对比, sgp4, hpop
|
||||
summary: 各种传播方法的对比,以及各自的开销与精度。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: 参考系
|
||||
tags: 参考系, 惯性系, 地固系, 大地坐标, 旋转
|
||||
summary: 惯性系与地固系、在类型中携带参考系的位置表示,以及二者之间的旋转。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: SGP4 与 SDP4 传播
|
||||
tags: sgp4, sdp4, 传播, 两行元素集
|
||||
summary: 两行元素集所拟合的解析传播器、其两个分支,以及坐标系与单位。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: 标准与来源
|
||||
tags: 标准, 引用, 出处
|
||||
summary: 各内核所遵循的公开标准与论文,以及引用方式。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: 时间尺度
|
||||
tags: 时间, utc, tai, tt, gps
|
||||
summary: UTC、TAI、TT 与 GPS,以及为什么时刻只存储在单一尺度上。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: 两行元素格式
|
||||
tags: 两行元素集, 元素, 格式, 校验和
|
||||
summary: 两行元素集的定宽列、校验和规则,以及两个容易误读的约定。
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
Unit tests live beside the code they test and cover the parser, the codec,
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@
|
|||
</svelte:head>
|
||||
|
||||
<div class="app">
|
||||
<div class="sidebar-frame">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<a href="#/">APSIS</a>
|
||||
|
|
@ -192,6 +193,7 @@
|
|||
{/if}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
|
|
|
|||
|
|
@ -170,16 +170,22 @@ hr {
|
|||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
/* 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 look cut off on a long
|
||||
page. */
|
||||
.sidebar-frame {
|
||||
flex: 0 0 var(--sidebar-width);
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 1.25rem 0 2rem;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem 0 2rem;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
|
|
@ -271,7 +277,7 @@ hr {
|
|||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1 1 auto;
|
||||
flex: 0 1 auto;
|
||||
max-width: 34rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
|
@ -404,17 +410,61 @@ button.ghost:hover:not(:disabled) {
|
|||
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 {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.grid.cards {
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
}
|
||||
|
||||
.grid.metrics {
|
||||
|
|
@ -438,8 +488,7 @@ button.ghost:hover:not(:disabled) {
|
|||
.metric .label {
|
||||
color: var(--text-faint);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
|
@ -463,6 +512,7 @@ button.ghost:hover:not(:disabled) {
|
|||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
|
|
@ -474,6 +524,7 @@ button.ghost:hover:not(:disabled) {
|
|||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
border-radius: var(--radius-small);
|
||||
padding: 0.05rem 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
|
|
@ -726,7 +777,11 @@ tbody tr:hover {
|
|||
|
||||
.graph-canvas {
|
||||
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);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
|
|
@ -820,6 +875,19 @@ tbody tr:hover {
|
|||
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 {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.95rem;
|
||||
|
|
@ -957,6 +1025,35 @@ tbody tr:hover {
|
|||
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. */
|
||||
.chip-row {
|
||||
display: flex;
|
||||
|
|
@ -1061,6 +1158,18 @@ tbody tr:hover {
|
|||
|
||||
/* 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) {
|
||||
.app {
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -37,9 +37,14 @@
|
|||
|
||||
/** The radius of a node marker. */
|
||||
const NODE_RADIUS = 9;
|
||||
/** Width and height of the layout space. */
|
||||
const WIDTH = 1200;
|
||||
const HEIGHT = 760;
|
||||
/**
|
||||
* Width and height of the layout space.
|
||||
*
|
||||
* 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 span = $state(1);
|
||||
|
|
@ -120,14 +125,84 @@
|
|||
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(() => {
|
||||
placements = layout(graph);
|
||||
span = 1;
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
fitToNodes();
|
||||
});
|
||||
|
||||
/** The transform of the layout viewport. */
|
||||
|
|
@ -166,16 +241,14 @@
|
|||
span = next;
|
||||
}
|
||||
|
||||
/** Resets the viewport. */
|
||||
/** Returns the viewport to the fitted view. */
|
||||
function reset() {
|
||||
span = 1;
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
fitToNodes();
|
||||
}
|
||||
</script>
|
||||
|
||||
<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.edges.length} {tr('graph.edges', lang)}</span>
|
||||
<span class="badge">{tr('graph.zoom', lang)} {span.toFixed(2)}×</span>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ export interface Text {
|
|||
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. */
|
||||
export function isLanguage(value: unknown): value is Language {
|
||||
return value === 'zh' || value === 'en';
|
||||
|
|
@ -77,6 +83,19 @@ export function pick(value: Text | null | undefined, target?: Language): string
|
|||
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. */
|
||||
const STRINGS: Record<string, Text> = {
|
||||
'app.title': { zh: 'APSIS', en: 'APSIS' },
|
||||
|
|
@ -172,7 +191,7 @@ const STRINGS: Record<string, Text> = {
|
|||
'graph.edges': { zh: '边', en: 'edges' },
|
||||
'graph.zoom': { zh: '缩放', en: 'zoom' },
|
||||
'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.interface': { zh: '接口:', en: 'Interface:' },
|
||||
'graph.crateNote': {
|
||||
|
|
@ -233,6 +252,50 @@ const STRINGS: Record<string, Text> = {
|
|||
'common.notFound': { zh: '不存在页面', en: 'There is no page at' },
|
||||
'common.goHome': { zh: '前往总览', en: 'Go to the overview' },
|
||||
'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. */
|
||||
|
|
@ -249,4 +312,37 @@ export function t(key: string): string {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ export interface Bilingual {
|
|||
en: string;
|
||||
}
|
||||
|
||||
/** A list of strings carried in both languages. */
|
||||
export interface BilingualList {
|
||||
zh: string[];
|
||||
en: string[];
|
||||
}
|
||||
|
||||
/** Service health. */
|
||||
export interface Health {
|
||||
status: string;
|
||||
|
|
@ -42,10 +48,11 @@ export interface DocumentForm {
|
|||
export interface Document {
|
||||
id: string;
|
||||
chapter: string;
|
||||
tags: string[];
|
||||
prerequisites: string[];
|
||||
references: string[];
|
||||
forms: DocumentForm[];
|
||||
/** Tags in both languages. */
|
||||
tags: BilingualList;
|
||||
}
|
||||
|
||||
/** The document listing, grouped by chapter. */
|
||||
|
|
@ -55,7 +62,7 @@ export interface DocumentIndex {
|
|||
id: string;
|
||||
title: 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;
|
||||
summary: string;
|
||||
chapter: string;
|
||||
/** The chapter title in both languages, so a result is readable. */
|
||||
chapter_title: Bilingual;
|
||||
score: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
import { getJson } from '../lib/api';
|
||||
import { navigate } from '../lib/router';
|
||||
import { language, pick, tr } from '../lib/i18n';
|
||||
import { KIND_GROUPS } from '../lib/i18n';
|
||||
import Signature from '../lib/Signature.svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -23,6 +24,8 @@
|
|||
id: string | null;
|
||||
}
|
||||
|
||||
type Language = import('../lib/i18n').Language;
|
||||
|
||||
let { index, id }: Props = $props();
|
||||
|
||||
/** The current language, read so that the text re-renders on a switch. */
|
||||
|
|
@ -96,6 +99,17 @@
|
|||
})).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. */
|
||||
function openItem(name: string) {
|
||||
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>
|
||||
|
||||
<div class="page-header">
|
||||
|
|
@ -155,9 +155,10 @@
|
|||
|
||||
{#each groups as group}
|
||||
<h3 id={`kind-${group.kind}`}>
|
||||
{group.kind === 'fn' ? 'functions' : `${group.kind}s`}
|
||||
{groupHeading(group.kind, lang)}
|
||||
<span class="badge">{group.items.length}</span>
|
||||
</h3>
|
||||
<div class="item-grid">
|
||||
{#each group.items as candidate (candidate.name + candidate.signature)}
|
||||
<article class="api-item">
|
||||
<header>
|
||||
|
|
@ -186,6 +187,7 @@
|
|||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{:else if item}
|
||||
<!-- One item on its own. -->
|
||||
|
|
@ -230,6 +232,7 @@
|
|||
<section class="card">
|
||||
<h3 style="margin-top: 0">{tr('api.find', lang)}</h3>
|
||||
<input
|
||||
class="filter-field"
|
||||
type="search"
|
||||
placeholder={`${tr('api.filter', lang)} (${index.item_count})`}
|
||||
bind:value={filter}
|
||||
|
|
@ -256,9 +259,9 @@
|
|||
<header>
|
||||
<span class="name">{candidate.name}</span>
|
||||
<span class="badge">{candidate.layer}</span>
|
||||
<span class="badge">{candidate.items}</span>
|
||||
<span class="badge">{candidate.items} {tr('api.itemCount', lang)}</span>
|
||||
</header>
|
||||
<p>{firstSentence(pick(candidate.summary.plain, lang))}</p>
|
||||
<p class="card-clamp">{pick(candidate.summary.plain, lang)}</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
import type { DocumentIndex } from '../lib/types';
|
||||
import { navigate } from '../lib/router';
|
||||
import { language, pick, tr } from '../lib/i18n';
|
||||
import { language, pick, pickList, tr } from '../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
index: DocumentIndex | null;
|
||||
|
|
@ -37,18 +37,17 @@
|
|||
{#each chapter.documents as document}
|
||||
<article class="card">
|
||||
<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)}
|
||||
</p>
|
||||
<div>
|
||||
{#each document.tags as tag}
|
||||
<div class="card-tags">
|
||||
{#each pickList(document.tags, lang) as tag}
|
||||
<span class="tag">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
class="ghost"
|
||||
class="ghost card-action"
|
||||
type="button"
|
||||
style="margin-top: 0.75rem;"
|
||||
onclick={() => navigate(`/docs/${document.id}`)}>{tr('docs.read', lang)}</button>
|
||||
</article>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
*/
|
||||
import type { Document, DocumentForm } from '../lib/types';
|
||||
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 {
|
||||
document: Document | null;
|
||||
|
|
@ -101,7 +101,7 @@
|
|||
<p style="color: var(--text-muted); font-size: 1.05rem;">{form.summary}</p>
|
||||
{/if}
|
||||
<div>
|
||||
{#each document.tags as tag}
|
||||
{#each pickList(document.tags, lang) as tag}
|
||||
<span class="tag">{tag}</span>
|
||||
{/each}
|
||||
{#if otherLanguage}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
import type { FlowIndex, Graph, GraphNode } from '../lib/types';
|
||||
import { getJson } from '../lib/api';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -96,7 +96,7 @@
|
|||
|
||||
<div class="legend">
|
||||
{#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}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
import { getJson } from '../lib/api';
|
||||
import { navigate } from '../lib/router';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
|
||||
<div class="legend">
|
||||
{#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}
|
||||
</div>
|
||||
|
||||
|
|
@ -102,7 +102,7 @@
|
|||
<h3 style="margin: 0;">{pick(selectedNode.label, lang)}</h3>
|
||||
<span class="badge">{selectedNode.kind}</span>
|
||||
<span class="badge" style={`color:${groupColor(selectedNode.group)}`}
|
||||
>{selectedNode.group}</span>
|
||||
>{groupLabel(selectedNode.group, lang)}</span>
|
||||
</header>
|
||||
<p style="color: var(--text-muted);">{pick(selectedNode.summary, lang)}</p>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* that a reader can tell why it was returned.
|
||||
*/
|
||||
import type { SearchResponse } from '../lib/types';
|
||||
import { language, tr } from '../lib/i18n';
|
||||
import { language, pick, tr } from '../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
response: SearchResponse | null;
|
||||
|
|
@ -62,8 +62,7 @@
|
|||
<a class="result-title" href={`#/docs/${hit.id}`}>{hit.title}</a>
|
||||
<p class="result-summary">{excerpt(hit.summary, query)}</p>
|
||||
<div class="result-meta">
|
||||
<span class="badge">{hit.chapter}</span>
|
||||
<span class="badge">{hit.language}</span>
|
||||
<span class="badge">{pick(hit.chapter_title, lang)}</span>
|
||||
<span class="badge">{tr('search.score', lang)} {hit.score.toFixed(2)}</span>
|
||||
</div>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -52,9 +52,13 @@
|
|||
const currentRegistry = registry;
|
||||
const currentId = id;
|
||||
if (!currentRegistry) return;
|
||||
const found = currentId
|
||||
? (currentRegistry.tools.find((candidate) => candidate.id === currentId) ?? null)
|
||||
: null;
|
||||
// With no tool named in the route the first one is shown, so the page
|
||||
// opens on a working form rather than an invitation to pick.
|
||||
const wanted =
|
||||
(currentId
|
||||
? currentRegistry.tools.find((candidate) => candidate.id === currentId)
|
||||
: undefined) ?? currentRegistry.tools[0];
|
||||
const found = wanted ?? null;
|
||||
tool = found;
|
||||
result = null;
|
||||
error = null;
|
||||
|
|
@ -125,18 +129,17 @@
|
|||
<p class="empty">{tr('tools.loading', lang)}</p>
|
||||
{:else}
|
||||
{#each categories as group}
|
||||
<section style="margin-bottom: 1.25rem;">
|
||||
<h3 style="margin-bottom: 0.5rem;">{pick(group.category, lang)}</h3>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 0.4rem;">
|
||||
<section style="margin-bottom: 2rem;">
|
||||
<h3 style="margin-bottom: 0.75rem;">{pick(group.category, lang)}</h3>
|
||||
<div class="grid cards">
|
||||
{#each group.tools as candidate}
|
||||
<a
|
||||
class="card tool-card"
|
||||
class:selected={id === 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>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ impl Router {
|
|||
"summary" => hit.summary.clone(),
|
||||
"language" => hit.language.tag(),
|
||||
"chapter" => hit.chapter.clone(),
|
||||
"chapter_title" => chapter_title(&self.state, &hit.chapter),
|
||||
"score" => hit.score,
|
||||
}
|
||||
})
|
||||
|
|
@ -197,7 +198,7 @@ impl Router {
|
|||
let body = json_object! {
|
||||
"id" => document.id.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>>(),
|
||||
"references" => document.references().iter().map(|id| Value::from(id.as_str())).collect::<Vec<Value>>(),
|
||||
"forms" => forms,
|
||||
|
|
@ -424,7 +425,7 @@ fn document_index(state: &AppState) -> Value {
|
|||
"id" => document.id.clone(),
|
||||
"title" => bilingual("title", document),
|
||||
"summary" => bilingual("summary", document),
|
||||
"tags" => document.tags.iter().map(|tag| Value::from(tag.as_str())).collect::<Vec<Value>>(),
|
||||
"tags" => tag_lists(document),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -542,6 +543,22 @@ fn flow_list_json(flows: &[Graph]) -> Value {
|
|||
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.
|
||||
///
|
||||
/// A display label and a summary are carried in both languages when the
|
||||
|
|
|
|||
|
|
@ -73,8 +73,10 @@ pub struct Document {
|
|||
pub id: String,
|
||||
/// Chapter identifier the document belongs to.
|
||||
pub chapter: String,
|
||||
/// Free form tags.
|
||||
/// Free form tags in the language of the English form.
|
||||
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.
|
||||
pub prerequisites: Vec<String>,
|
||||
/// The English form. Every document has one, and it is the fallback
|
||||
|
|
@ -105,6 +107,7 @@ impl Document {
|
|||
id: id.to_string(),
|
||||
chapter: chapter_of(text),
|
||||
tags: list_of(text, "tags"),
|
||||
tags_zh: Vec::new(),
|
||||
prerequisites: list_of(text, "prerequisites"),
|
||||
english: variant,
|
||||
chinese: None,
|
||||
|
|
@ -218,6 +221,14 @@ fn strip_leading_title(body: &str, title: &str) -> 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.
|
||||
fn field_of(header: &str, key: &str) -> Option<String> {
|
||||
for line in header.lines() {
|
||||
|
|
|
|||
|
|
@ -683,6 +683,18 @@ fn strip_inline(text: &str) -> String {
|
|||
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 let Some((label, _, end)) = parse_link(&characters, index) {
|
||||
output.push_str(&label);
|
||||
|
|
@ -809,4 +821,15 @@ mod tests {
|
|||
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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,8 +73,10 @@ pub fn load_documents() -> Vec<Document> {
|
|||
let Some(translation) = DocumentVariant::parse(text) else {
|
||||
continue;
|
||||
};
|
||||
let tags_zh = crate::docs::document::tags_of(text);
|
||||
if let Some(document) = documents.get_mut(stem) {
|
||||
document.chinese = Some(translation);
|
||||
document.tags_zh = tags_zh;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue