web: align the interface layout and translate the remaining captions
This commit is contained in:
parent
994f871ff2
commit
eb65230a44
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: 工作区架构
|
title: 工作区架构
|
||||||
|
tags: 架构, 分层, crate
|
||||||
summary: apsis-rs 的分层结构、每层的 crate 以及依赖规则。
|
summary: apsis-rs 的分层结构、每层的 crate 以及依赖规则。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: 日历与约化儒略日
|
title: 日历与约化儒略日
|
||||||
|
tags: 时间, 日历, 儒略日, mjd
|
||||||
summary: 日编号系统、外推格里高利历,以及转换算法。
|
summary: 日编号系统、外推格里高利历,以及转换算法。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: 地球自转角
|
title: 地球自转角
|
||||||
|
tags: 参考系, 地球自转, iers, ut1
|
||||||
summary: 惯性系与地固系之间的夹角,以及给出该值的线性表达式。
|
summary: 惯性系与地固系之间的夹角,以及给出该值的线性表达式。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: 星历采样与插值
|
title: 星历采样与插值
|
||||||
|
tags: 星历, 插值, 采样, hermite
|
||||||
summary: 何时采样一次并多次查询轨迹,以及插值所付出的精度代价。
|
summary: 何时采样一次并多次查询轨迹,以及插值所付出的精度代价。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: WGS 84 椭球与大地坐标
|
title: WGS 84 椭球与大地坐标
|
||||||
|
tags: 大地测量, wgs84, 纬度, 椭球
|
||||||
summary: 纬度、经度与椭球高,以及到直角坐标的闭式转换。
|
summary: 纬度、经度与椭球高,以及到直角坐标的闭式转换。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: 开普勒轨道元素
|
title: 开普勒轨道元素
|
||||||
|
tags: 轨道, 开普勒, 元素, 近点角
|
||||||
summary: 六个经典轨道元素、二体解与三种近点角。
|
summary: 六个经典轨道元素、二体解与三种近点角。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: 闰秒
|
title: 闰秒
|
||||||
|
tags: 时间, utc, 闰秒, iers
|
||||||
summary: UTC 为何存在阶跃间断,以及内嵌闰秒表如何处理它们。
|
summary: UTC 为何存在阶跃间断,以及内嵌闰秒表如何处理它们。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: 概念总览
|
title: 概念总览
|
||||||
|
tags: 总览, 范围
|
||||||
summary: ApsisRS 计算内核的覆盖范围,以及本概念文档集的组织方式。
|
summary: ApsisRS 计算内核的覆盖范围,以及本概念文档集的组织方式。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: 轨道摄动
|
title: 轨道摄动
|
||||||
|
tags: 轨道, 摄动, 阻力, 扁率, 三体
|
||||||
summary: 使轨道偏离椭圆的各种力,以及各模型包含其中哪些。
|
summary: 使轨道偏离椭圆的各种力,以及各模型包含其中哪些。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: 传播器选择
|
title: 传播器选择
|
||||||
|
tags: 传播, 对比, sgp4, hpop
|
||||||
summary: 各种传播方法的对比,以及各自的开销与精度。
|
summary: 各种传播方法的对比,以及各自的开销与精度。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: 参考系
|
title: 参考系
|
||||||
|
tags: 参考系, 惯性系, 地固系, 大地坐标, 旋转
|
||||||
summary: 惯性系与地固系、在类型中携带参考系的位置表示,以及二者之间的旋转。
|
summary: 惯性系与地固系、在类型中携带参考系的位置表示,以及二者之间的旋转。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: SGP4 与 SDP4 传播
|
title: SGP4 与 SDP4 传播
|
||||||
|
tags: sgp4, sdp4, 传播, 两行元素集
|
||||||
summary: 两行元素集所拟合的解析传播器、其两个分支,以及坐标系与单位。
|
summary: 两行元素集所拟合的解析传播器、其两个分支,以及坐标系与单位。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: 标准与来源
|
title: 标准与来源
|
||||||
|
tags: 标准, 引用, 出处
|
||||||
summary: 各内核所遵循的公开标准与论文,以及引用方式。
|
summary: 各内核所遵循的公开标准与论文,以及引用方式。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: 时间尺度
|
title: 时间尺度
|
||||||
|
tags: 时间, utc, tai, tt, gps
|
||||||
summary: UTC、TAI、TT 与 GPS,以及为什么时刻只存储在单一尺度上。
|
summary: UTC、TAI、TT 与 GPS,以及为什么时刻只存储在单一尺度上。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: 两行元素格式
|
title: 两行元素格式
|
||||||
|
tags: 两行元素集, 元素, 格式, 校验和
|
||||||
summary: 两行元素集的定宽列、校验和规则,以及两个容易误读的约定。
|
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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue