@12-apps/routing

中文文档
1.5.1发布于 2026/11/07MITES Module零依赖构建来源已验证近 30 天下载 3.1万

Road routes through an ordered list of stops, with the routing service chosen by host configuration (openrouteservice, OSRM, Google Routes, or your own adapter) and a straight-line fallback when none answers, plus a MapLibre route map for web hosts that d

原始使用文档
文档目录5 节
  1. Server
  2. Adding a provider
  3. Google Routes
  4. Web
  5. Wiring

@12-apps/routing

Road routes through an ordered list of stops, and a map that draws them.

  • The routing service is configuration. A host lists the providers it wants, in order: openrouteservice, OSRM, Google Routes, or its own adapter. The planner keeps the first one that answers.
  • There is always a route. When every provider fails, times out or is not configured, the answer is straight segments between the points, flagged fallback: true. It has distances and no durations, because a time computed from a straight line would be an invention.
  • The map draws what it is given. RouteMap (MapLibre GL, OpenFreeMap by default) renders markers, places, numbered stops, a dashed planned line and a solid travelled line. It fetches nothing, and every word and colour comes from the host.

Server

import { createApiRouting, openRouteServiceProvider, osrmProvider } from "@12-apps/routing/server";

const routing = createApiRouting({
  providers: [
    openRouteServiceProvider({ apiKey: secrets.ROUTING_ORS_API_KEY }),
    osrmProvider({ baseUrl: secrets.ROUTING_OSRM_URL }),
  ],
  timeoutMs: 6_000,
  authorize: (actor) => actor.canPlanRoutes,
});

// In-process (a job that saves a trip's route):
const route = await routing.planRoute({ origin: shop, stops: [a, b], returnTo: shop });
// route.geometry, route.legs[i].durationS (null on fallback), route.provider, route.fallback

// Or mount `routing.routes` (one POST /route) behind your own router.

The planner keeps the stop order it is given; sequencing stops is the host's job. It never throws for provider trouble. It throws RouteRequestError only for a request that cannot be routed (fewer than two points, or an invalid point or 0,0) and for a provider listed twice.

Adding a provider

Implement RoutingProvider:

const myProvider: RoutingProvider = {
  name: "my-service",
  async route(request, { fetch, signal }) {
    // …call the service with `fetch` and `signal`…
    return { ok: true, geometry, legs }; // or { ok: false, kind: "http", status }
  },
};

Add it to the host's list. Nothing else changes.

Google Routes

Google's Maps Platform terms do not allow its route content to be displayed on a non-Google map. Do not list googleRoutesProvider if you draw with RouteMap over OpenFreeMap or any other non-Google basemap.

Web

import { createWebRouting, ROUTE_MAP_COPY } from "@12-apps/routing/react";

const { RouteMap } = createWebRouting({
  copy: ROUTE_MAP_COPY["pt-BR"],
  theme: { planned, travelled, done, next, pending, ink, paper, place, control, controlBorder },
});

<RouteMap
  height={480}
  markers={couriers.map((c) => ({ id: c.id, position: c.point, text: c.initials, ariaLabel: c.label, color: c.color, onSelect: () => select(c.id) }))}
  places={[{ id: "shop", position: shop, label: "Loja" }]}
  stops={stops}
  planned={route?.geometry}
  travelled={fixes}
  fitKey={tenantSlug}
/>;
  • travelledParts draws the solid line as separate strokes (a track with a gap that must not be bridged by a straight line); it wins over travelled.
  • maplibre-gl is an optional peer dependency: install it in the web host. It is imported the first time a map mounts, so a page without a map never downloads it.
  • The map fits its content once, and again only when fitKey changes or the fit control is pressed. A data refresh never moves a map someone panned.
  • Markers that overlap on screen fold into one group button. onGroupSelect receives their ids; without that callback, the map zooms in on them.
  • focus={{ key, points }} fits to just those points (a selected trip) whenever key changes — a viewer's selection, never a refresh. focus={{ key, points, zoom: 16 }} centres on their bounds at exactly that zoom instead, inside the same padding and insets a fit keeps clear (far-apart points may fall outside the frame; a new zoom under the same key does nothing): a fit pads a lone point to a few hundred metres and keeps the controls clear, which on a small map stops short of the street.
  • insets={{ bottom: legendHeight }} tells the fit what the host's own overlays cover, so fitted pins never land under a legend or a sheet.
  • For a small map (a card): placeLabels="at-point" draws a place's label on its point instead of lifted above a pin, and attribution="compact" folds the basemap credit into an "i" that opens it.
  • A stop with emphasized: true (the one the screen is about) is drawn above the pins; other stops sit beneath them.
  • A marker's (or group's) tag — its pill and tail — steps aside from stop badges and place labels (tagPlacement="avoid", the default). After every draw, fit, pan and zoom it keeps its current side while that is clear; otherwise it takes the first clear side of its pin in the order above, below, right, left (a side other than the current one must be clear by a few pixels, so a jittering GPS fix never flips it), and when every side is blocked, the least-covering one. A stop or place under the pin's own point (a courier arriving) does not count. Stops, places and the pin's point never move; the tail follows the side, and the element is restyled in place, so keyboard focus survives. tagPlacement="fixed" keeps every tag above, as before this release.
  • DOM change: every marker and group element now carries data-tag-side="above|below|right|left" (in both modes), so a host's DOM snapshot of the map gains that attribute. A host's own loadMapLibre fake needs Marker#setOffset for a tag to move.
  • controls={{ placement: "top-left" }} moves the zoom and fit column to the other top corner when the host's floating chrome covers the top-right.
  • A draggable place: places={[{ id, position, label, draggable: { ariaLabel, onMove, onDrop } }]} turns the place into a pin the viewer drags (mouse or one finger; MapLibre's own marker drag) or focuses and nudges with the arrow keys (5 m from zoom 15, 50 m below it; stepMetres(zoom) overrides). The host owns the position: onMove reports every move, onDrop where it came to rest, and the host passes the position back. The element is kept across moves, so focus stays. disabled: true holds it (a save in flight).
  • Circles and dots: circles={[{ id, center, radiusMetres, dashed? }]} draws rings on the ground (solid and faintly filled in theme.travelled, or dashed in theme.ink), redrawn whenever the array changes, so a host that moves a centre with a dragged pin redraws its rings live; "fit all" includes them. dots={[{ id, position, filled }]} draws small points under the pins, filled with theme.ink or hollow. A map with neither adds no layer for them.
  • A place's colour, ghost places and tethers: color on a place fills its draggable pin (over theme.travelled), its label (over theme.place) or a ghost's ring and label (over theme.ink), e.g. with the host's brand colour. ghost: true draws a reference position (where a pin was, or would be) as a dashed hollow ring with a quiet label, never a pin. tethers={[{ id, from, to, color? }]} draws thin dashed segments in theme.ink with the circles; "fit all" leaves them out.
  • copy={{ fitAll: "…" }} overrides the surface's words for one mount; controls={{ zoomWhenNarrow: true }} keeps the zoom buttons under 600 px, where they are hidden by default; onZoomChange(zoom) reports the zoom once ready and after every zoom.
  • controls={{ fitAsText: true }} draws the fit control as its word (copy.fitAll) instead of the corners glyph.
  • controls={{ onZoomOut }} replaces what the zoom-out control does (a host leaving a street view for its own wider map); wheel and pinch still zoom.
  • scaleBar={(metres) => "100 m"} draws a scale bar bottom-left: the longest nice distance (50 m … 50 km) within a quarter of the map's width, re-measured after every pan and zoom. A host fake needs getZoom and getCenter.
  • No WebGL, a library or style that does not arrive within readyTimeoutMs (default 15 s), shows copy.mapError with a retry. The region's data-state reads loading, ready or error. The host's overlay is hidden while the error shows — the error panel carries its own retry.

Wiring

./manifest declares server: ["http"] and web: ["surface"]. http.create is createApiRouting and surface.create is createWebRouting.

版本与发布记录

@12-apps/routing 共发布 9 个版本,首次发布于 2026/10/07;过去 12 个月发布了 9 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
1.5.1latest2026/11/07今天
1.5.02026/11/07今天
1.4.02026/11/07今天
1.3.02026/11/07今天
1.2.02026/10/0830 天前
1.1.12026/10/0830 天前

关于 @12-apps/routing 的常见问题

@12-apps/routing 是什么?

Road routes through an ordered list of stops, with the routing service chosen by host configuration (openrouteservice, OSRM, Google Routes, or your own adapter) and a straight-line fallback when none answers, plus a MapLibre route map for web hosts that d

如何安装 @12-apps/routing?

在项目目录运行 npm install @12-apps/routing,或使用 pnpm add / yarn add / bun add。

@12-apps/routing 支持 TypeScript 吗?

该包的 package.json 没有声明类型入口。需要类型提示时,可以在 npm 上查找社区维护的 @types/12-apps__routing 是否存在。

@12-apps/routing 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@12-apps/routing 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@12-apps/routing 可以商用吗?

@12-apps/routing 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@12-apps/routing 的下载量有多少?

根据 npm 官方下载统计,@12-apps/routing 在 2026/10/04 – 2026/11/02 这 30 天内被下载 31,232 次,日均约 1,041 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@12-apps/routing 还在维护吗?

最新版本 1.5.1 发布于 2026/11/07(今天);至今共发布 9 个版本,其中过去 12 个月发布了 9 个;首次发布于 2026/10/07。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。