Rebuild the served tree after merging main
This commit is contained in:
commit
301e6d0419
257 changed files with 9594 additions and 43587 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -145,3 +145,6 @@ data-private/
|
|||
/public/assets/
|
||||
/public/__gallery/
|
||||
/public/index.html
|
||||
|
||||
# Local plugin backends (backend/<name>/) — the compiled binaries are rebuilt from source, never committed.
|
||||
/backend/bin/
|
||||
|
|
|
|||
|
|
@ -616,6 +616,14 @@ The studio is a plugin like any other: `plugin-designs/plugin-studio/plugin-stud
|
|||
| `bun run gates:pre` · `bun run gates:post` | The checks that keep `main` working |
|
||||
| `bun run shots` | Retake these screenshots (`ONLY=<name>` for one) |
|
||||
|
||||
### Run it in the cloud
|
||||
|
||||
**One shared dev server for the whole team, no laptop setup.** A container clones the repo, follows
|
||||
`main`, and hot-reloads every change an agent lands, so everyone sees the Plugin Studio update in
|
||||
their browser within seconds, with no rebuild. Add OIDC settings and it puts a "Sign in with *your
|
||||
provider*" page in front of everything, open only to the groups you allow. Recipe and settings:
|
||||
[deployment/cloud/](./deployment/cloud/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
|
|
|||
456
backend/rudder-engine/devices.go
Normal file
456
backend/rudder-engine/devices.go
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
// devices.go — microphone discovery, hot-plug, and the sound-server environment.
|
||||
//
|
||||
// The Jitsi Meet pattern (lib-jitsi-meet modules/RTC/RTCUtils.js), done natively because Rudder records
|
||||
// outside the browser:
|
||||
//
|
||||
// Jitsi (browser) Rudder (native, PipeWire/PulseAudio)
|
||||
// navigator.mediaDevices.enumerateDevices() → pactl -f json list sources
|
||||
// 'devicechange' event + polling fallback → pactl subscribe (+ re-list on every source/card event)
|
||||
// compareAvailableMediaDevices(new) → sameDevices(old, new) — only emit on a real change
|
||||
// getUserMedia({audio:{deviceId:{exact:id}}}) → parec --device=<id>
|
||||
//
|
||||
// Jitsi also reports the device list to analytics (sendDeviceListToAnalytics). Rudder never does:
|
||||
// the list goes to the local plugin over SSE and nowhere else.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// audioEnv gives child processes a path to the user's sound server even when the engine was launched
|
||||
// from a context without a desktop session (a detached shell, a service, an agent's terminal). Measured
|
||||
// 2026-09-27: started with no XDG_RUNTIME_DIR, parec exited instantly with "Connection refused" and every
|
||||
// recording captured nothing, although the socket sat at /run/user/<uid>/pulse/native all along.
|
||||
func audioEnv() []string {
|
||||
env := os.Environ()
|
||||
rt := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if rt == "" {
|
||||
rt = fmt.Sprintf("/run/user/%d", os.Getuid())
|
||||
if st, err := os.Stat(rt); err == nil && st.IsDir() {
|
||||
env = append(env, "XDG_RUNTIME_DIR="+rt)
|
||||
}
|
||||
}
|
||||
// systemctl --user (a headset-reset command may restart PipeWire) needs the session bus.
|
||||
if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {
|
||||
if _, err := os.Stat(rt + "/bus"); err == nil {
|
||||
env = append(env, "DBUS_SESSION_BUS_ADDRESS=unix:path="+rt+"/bus")
|
||||
}
|
||||
}
|
||||
if os.Getenv("PULSE_SERVER") == "" {
|
||||
if _, err := os.Stat(rt + "/pulse/native"); err == nil {
|
||||
env = append(env, "PULSE_SERVER=unix:"+rt+"/pulse/native")
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// Device is one microphone as the plugin shows it.
|
||||
type Device struct {
|
||||
ID string `json:"id"` // the sound server's source name: what parec --device takes
|
||||
Label string `json:"label"` // human name ("My Headset", "Built-in Audio Analog Stereo")
|
||||
Bus string `json:"bus"` // bluetooth | usb | pci | ...
|
||||
Default bool `json:"default"` // the system's default input
|
||||
Muted bool `json:"muted"`
|
||||
Volume string `json:"volume"` // e.g. "52%"
|
||||
State string `json:"state"` // RUNNING | IDLE | SUSPENDED
|
||||
}
|
||||
|
||||
// DeviceList is the whole picture: the mics, plus sound cards that have a mic but are switched off
|
||||
// (the laptop's built-in card, profile "off", is the common case — its mic simply doesn't exist until
|
||||
// the card is turned on in Sound settings).
|
||||
type DeviceList struct {
|
||||
Devices []Device `json:"devices"`
|
||||
Default string `json:"default"`
|
||||
OffCards []string `json:"offCards"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func runPactl(args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("pactl", args...)
|
||||
cmd.Env = audioEnv()
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
func listDevices() DeviceList {
|
||||
out := DeviceList{Devices: []Device{}, OffCards: []string{}}
|
||||
if _, err := exec.LookPath("pactl"); err != nil {
|
||||
out.Error = "pactl not found: install pipewire-pulse or pulseaudio-utils to list microphones"
|
||||
return out
|
||||
}
|
||||
if b, err := runPactl("get-default-source"); err == nil {
|
||||
out.Default = strings.TrimSpace(string(b))
|
||||
}
|
||||
b, err := runPactl("-f", "json", "list", "sources")
|
||||
if err != nil {
|
||||
out.Error = "cannot reach the sound server (pactl list sources failed: " + err.Error() + ")"
|
||||
return out
|
||||
}
|
||||
var raw []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
State string `json:"state"`
|
||||
Mute bool `json:"mute"`
|
||||
Volume map[string]any `json:"volume"`
|
||||
Properties map[string]any `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
out.Error = "unexpected pactl output: " + err.Error()
|
||||
return out
|
||||
}
|
||||
for _, s := range raw {
|
||||
// A ".monitor" source is the loopback of a SPEAKER, not a microphone: listing it would let the
|
||||
// user "record" their own music. Hidden, as Jitsi lists only audioinput kinds.
|
||||
if strings.HasSuffix(s.Name, ".monitor") || s.Properties["device.class"] == "monitor" {
|
||||
continue
|
||||
}
|
||||
d := Device{ID: s.Name, Label: s.Description, State: s.State, Muted: s.Mute, Default: s.Name == out.Default}
|
||||
if bus, ok := s.Properties["device.bus"].(string); ok {
|
||||
d.Bus = bus
|
||||
}
|
||||
for _, ch := range s.Volume { // first channel is representative
|
||||
if m, ok := ch.(map[string]any); ok {
|
||||
if p, ok := m["value_percent"].(string); ok {
|
||||
d.Volume = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out.Devices = append(out.Devices, d)
|
||||
}
|
||||
// Cards whose active profile is "off" but which offer an input profile.
|
||||
if b, err := runPactl("-f", "json", "list", "cards"); err == nil {
|
||||
var cards []struct {
|
||||
Name string `json:"name"`
|
||||
ActiveProfile string `json:"active_profile"`
|
||||
Properties map[string]any `json:"properties"`
|
||||
Profiles map[string]struct {
|
||||
Sources int `json:"sources"`
|
||||
Available bool `json:"available"`
|
||||
} `json:"profiles"`
|
||||
}
|
||||
if json.Unmarshal(b, &cards) == nil {
|
||||
for _, c := range cards {
|
||||
if c.ActiveProfile != "off" {
|
||||
continue
|
||||
}
|
||||
for _, p := range c.Profiles {
|
||||
if p.Sources > 0 && p.Available {
|
||||
label, _ := c.Properties["device.description"].(string)
|
||||
if label == "" {
|
||||
label = c.Name
|
||||
}
|
||||
out.OffCards = append(out.OffCards, label)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameDevices mirrors Jitsi's compareAvailableMediaDevices: emit only when something a user would
|
||||
// notice changed (a mic came, went, was muted, or the default moved), not on every state flicker.
|
||||
func sameDevices(a, b DeviceList) bool {
|
||||
if a.Default != b.Default || len(a.Devices) != len(b.Devices) || a.Error != b.Error || len(a.OffCards) != len(b.OffCards) {
|
||||
return false
|
||||
}
|
||||
for i := range a.Devices {
|
||||
x, y := a.Devices[i], b.Devices[i]
|
||||
if x.ID != y.ID || x.Label != y.Label || x.Muted != y.Muted || x.Volume != y.Volume {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// deviceWatcher keeps the current list and pushes a "devices" event on every real change. It listens
|
||||
// to `pactl subscribe` (the native 'devicechange') and, like Jitsi, also polls as a fallback in case
|
||||
// the subscription dies.
|
||||
type deviceWatcher struct {
|
||||
mu sync.Mutex
|
||||
last DeviceList
|
||||
hub *hub
|
||||
logs *ring
|
||||
}
|
||||
|
||||
func (w *deviceWatcher) current() DeviceList {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.last
|
||||
}
|
||||
|
||||
func (w *deviceWatcher) refresh(reason string) {
|
||||
now := listDevices()
|
||||
w.mu.Lock()
|
||||
changed := !sameDevices(w.last, now)
|
||||
w.last = now
|
||||
w.mu.Unlock()
|
||||
if changed {
|
||||
w.logs.add("microphones (%s): %d found, default=%s %s", reason, len(now.Devices), now.Default, now.Error)
|
||||
w.hub.send(event{"type": "devices", "devices": now})
|
||||
}
|
||||
}
|
||||
|
||||
func (w *deviceWatcher) run() {
|
||||
w.refresh("startup")
|
||||
// Poll fallback (Jitsi polls enumerateDevices when devicechange is unavailable).
|
||||
go func() {
|
||||
for range time.Tick(5 * time.Second) {
|
||||
w.refresh("poll")
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
for {
|
||||
cmd := exec.Command("pactl", "subscribe")
|
||||
cmd.Env = audioEnv()
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err == nil && cmd.Start() == nil {
|
||||
sc := bufio.NewScanner(out)
|
||||
var debounce *time.Timer
|
||||
for sc.Scan() {
|
||||
l := sc.Text()
|
||||
if strings.Contains(l, " card ") || (strings.Contains(l, " source ") && !strings.Contains(l, "source-output")) {
|
||||
w.logs.add("sound-server event: %s", l)
|
||||
}
|
||||
if strings.Contains(l, " source ") || strings.Contains(l, " card ") || strings.Contains(l, " server ") {
|
||||
if debounce != nil {
|
||||
debounce.Stop()
|
||||
}
|
||||
debounce = time.AfterFunc(300*time.Millisecond, func() { w.refresh("hot-plug") })
|
||||
}
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
time.Sleep(3 * time.Second) // sound server restarted or unreachable: retry
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// micTest records ~3 s from one device WITHOUT transcribing and says, in plain words, whether the mic
|
||||
// works — Rudder's version of Jitsi's pre-join audio preview. Level events stream to the plugin while
|
||||
// it runs so the meter moves.
|
||||
func (e *engine) micTest(device string) event {
|
||||
cmds := recorderCmds(device)
|
||||
var cmd *exec.Cmd
|
||||
for _, c := range cmds {
|
||||
if _, err := exec.LookPath(c[0]); err == nil {
|
||||
cmd = exec.Command(c[0], c[1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if cmd == nil {
|
||||
return event{"ok": false, "verdict": "No recorder found (need parec, pw-record or arecord)."}
|
||||
}
|
||||
cmd.Env = audioEnv()
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
|
||||
var errBuf strings.Builder
|
||||
cmd.Stderr = &errBuf
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil || cmd.Start() != nil {
|
||||
return event{"ok": false, "verdict": "Could not start the recorder."}
|
||||
}
|
||||
defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
|
||||
|
||||
frame := make([]byte, 3200) // 100 ms
|
||||
peak, frames, zeroFrames := 0, 0, 0
|
||||
var sumSq float64
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := io.ReadFull(out, frame)
|
||||
if err != nil {
|
||||
why := strings.TrimSpace(errBuf.String())
|
||||
e.logs.add("mic test on %q failed after %d frames: %v | %s", device, frames, err, why)
|
||||
return event{"ok": false, "verdict": "The microphone produced no audio. " + why, "recorder": cmd.Path}
|
||||
}
|
||||
fp := 0
|
||||
for i := 0; i+1 < n; i += 2 {
|
||||
v := int(int16(uint16(frame[i]) | uint16(frame[i+1])<<8))
|
||||
sumSq += float64(v * v)
|
||||
if v < 0 {
|
||||
v = -v
|
||||
}
|
||||
if v > fp {
|
||||
fp = v
|
||||
}
|
||||
}
|
||||
if fp == 0 {
|
||||
zeroFrames++
|
||||
}
|
||||
if fp > peak {
|
||||
peak = fp
|
||||
}
|
||||
frames++
|
||||
e.hub.send(event{"type": "level", "peak": float64(fp) / 32768, "test": true})
|
||||
}
|
||||
rms := 0.0
|
||||
if frames > 0 {
|
||||
rms = math.Sqrt(sumSq/float64(frames*1600)) / 32768
|
||||
}
|
||||
res := event{"ok": true, "peak": float64(peak) / 32768, "rms": rms, "frames": frames}
|
||||
switch {
|
||||
case zeroFrames == frames:
|
||||
res["ok"] = false
|
||||
res["verdict"] = "Pure silence — the mic is connected but sends nothing. It may be muted, or a Bluetooth headset's mic link may have dropped (try reconnecting the headset or pick another mic)."
|
||||
case peak < 330: // below about -40 dBFS
|
||||
res["verdict"] = "Very quiet — Rudder can hear the mic, but barely. Speak closer or raise its input volume."
|
||||
default:
|
||||
res["verdict"] = "Working — Rudder can hear you."
|
||||
}
|
||||
e.logs.add("mic test on %q: peak=%d rms=%.4f frames=%d verdict=%s", device, peak, rms, frames, res["verdict"])
|
||||
return res
|
||||
}
|
||||
|
||||
// ─── Optional Bluetooth headset reset ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Some Bluetooth headsets drop the mic side of the HFP link (exact-zero audio while everything looks
|
||||
// connected; the kernel logs `hci0: corrupted SCO packet`). Re-opening the recorder does not revive
|
||||
// it; a full reset (disconnect → restart PipeWire/WirePlumber → reconnect) can. That is machine-specific,
|
||||
// so Rudder only runs one if RUDDER_BT_RESET names an executable that does it (exit 0 = headset works
|
||||
// again). Rate-limited: never more than once per 20 s.
|
||||
func btResetPath() string {
|
||||
p := os.Getenv("RUDDER_BT_RESET")
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() && st.Mode()&0o111 != 0 {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
resetMu sync.Mutex
|
||||
lastReset time.Time
|
||||
)
|
||||
|
||||
// runHeadsetReset runs the RUDDER_BT_RESET command and reports whether it said the headset works again.
|
||||
func runHeadsetReset(logs *ring) (ok bool, ran bool) {
|
||||
path := btResetPath()
|
||||
if path == "" {
|
||||
return false, false
|
||||
}
|
||||
resetMu.Lock()
|
||||
if time.Since(lastReset) < 20*time.Second {
|
||||
resetMu.Unlock()
|
||||
logs.add("headset reset skipped: last one was %.0fs ago (rate limit 20s)", time.Since(lastReset).Seconds())
|
||||
return false, false
|
||||
}
|
||||
lastReset = time.Now()
|
||||
resetMu.Unlock()
|
||||
|
||||
logs.add("running %s (headset reset + CVSD)", path)
|
||||
cmd := exec.Command(path)
|
||||
cmd.Env = audioEnv()
|
||||
done := make(chan struct{})
|
||||
var out []byte
|
||||
var err error
|
||||
go func() { out, err = cmd.CombinedOutput(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(60 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
logs.add("headset reset timed out after 60s")
|
||||
return false, true
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if l != "" {
|
||||
logs.add("[headset reset] %s", l)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
logs.add("headset reset failed: %v", err)
|
||||
return false, true
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
|
||||
// ─── Bluetooth voice-profile helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// btCard maps a Bluetooth mic (bluez_input.<MAC>.<n>, or "" = the default input) to its card.
|
||||
func btCard(device string) string {
|
||||
if device == "" {
|
||||
if b, err := runPactl("get-default-source"); err == nil {
|
||||
device = strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(device, "bluez_input.") {
|
||||
return ""
|
||||
}
|
||||
mac := strings.TrimPrefix(device, "bluez_input.")
|
||||
if i := strings.LastIndex(mac, "."); i > 0 {
|
||||
mac = mac[:i]
|
||||
}
|
||||
return "bluez_card." + mac
|
||||
}
|
||||
|
||||
func currentProfile(card string) string {
|
||||
if card == "" {
|
||||
return "(not bluetooth)"
|
||||
}
|
||||
b, err := runPactl("-f", "json", "list", "cards")
|
||||
if err != nil {
|
||||
return "?"
|
||||
}
|
||||
var cards []struct {
|
||||
Name string `json:"name"`
|
||||
ActiveProfile string `json:"active_profile"`
|
||||
}
|
||||
if json.Unmarshal(b, &cards) != nil {
|
||||
return "?"
|
||||
}
|
||||
for _, c := range cards {
|
||||
if c.Name == card {
|
||||
return c.ActiveProfile
|
||||
}
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// switchProfile forces a fresh voice link: CVSD if the card is on anything else, otherwise a bounce
|
||||
// through mSBC and back to CVSD (a same-profile set is a no-op and would not renegotiate the link).
|
||||
func switchProfile(card string, logs *ring) error {
|
||||
before := currentProfile(card)
|
||||
target := "headset-head-unit-cvsd"
|
||||
if before == target {
|
||||
if _, err := runPactl("set-card-profile", card, "headset-head-unit-msbc"); err != nil {
|
||||
logs.add("profile bounce via msbc failed: %v", err)
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
}
|
||||
if out, err := runPactl("set-card-profile", card, target); err != nil {
|
||||
return fmt.Errorf("pactl set-card-profile %s %s: %v %s", card, target, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond) // let the new SCO link and the source come up
|
||||
logs.add("voice profile %s → %s", before, currentProfile(card))
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchKernelBluetooth copies kernel Bluetooth messages into the log while a session runs
|
||||
// (`journalctl -k` is readable without root on this system).
|
||||
func watchKernelBluetooth(ctx context.Context, logs *ring) {
|
||||
cmd := exec.CommandContext(ctx, "journalctl", "-k", "-f", "-n", "0", "-o", "cat")
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil || cmd.Start() != nil {
|
||||
return
|
||||
}
|
||||
sc := bufio.NewScanner(out)
|
||||
for sc.Scan() {
|
||||
if l := sc.Text(); strings.Contains(strings.ToLower(l), "bluetooth") {
|
||||
logs.add("kernel: %s", l)
|
||||
}
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
5
backend/rudder-engine/go.mod
Normal file
5
backend/rudder-engine/go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module dx-maps/backend/rudder-engine
|
||||
|
||||
go 1.22.2
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3
|
||||
2
backend/rudder-engine/go.sum
Normal file
2
backend/rudder-engine/go.sum
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
797
backend/rudder-engine/main.go
Normal file
797
backend/rudder-engine/main.go
Normal file
|
|
@ -0,0 +1,797 @@
|
|||
// rudder-engine — the local speech backend for the rudder-voice plugin.
|
||||
//
|
||||
// WHY THIS EXISTS: rudder-voice is a DESKTOP-target plugin. Speech-to-text runs on the user's own
|
||||
// machine, never in the browser and never on a server. In dev, `bun run dev` starts this process and
|
||||
// Vite forwards /api/rudder/* to it; in the desktop app, Tauri runs it as a sidecar. Same binary,
|
||||
// same API, so "works in the Plugin Studio" means "works on desktop".
|
||||
//
|
||||
// browser / webview ──HTTP+SSE──► rudder-engine (127.0.0.1:RUDDER_PORT)
|
||||
// ├─ recorder: parec → pw-record → arecord (mic open ONLY while recording)
|
||||
// └─ WebSocket ──► nemo-speech serve (127.0.0.1:RUDDER_NEMO_PORT)
|
||||
// NVIDIA NeMo-Speech.cpp + Nemotron 3.5 ASR GGUF
|
||||
//
|
||||
// NO TELEMETRY, NO NETWORK: the runtime is NVIDIA NeMo-Speech.cpp (ggml). ONNX-based runtimes were
|
||||
// REJECTED: measured 2026-09-26, the tested ONNX GenAI runtime looked up a vendor telemetry host even
|
||||
// with telemetry "disabled". NeMo-Speech.cpp was traced with strace: zero sockets, zero DNS.
|
||||
// Its only network path is an optional model download that shells out to `curl` when the model is not
|
||||
// a local file — so this engine REFUSES to start unless RUDDER_MODEL is an existing local file.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ─── Config (env, with local defaults) ──────────────────────────────────────────────────────────
|
||||
|
||||
type config struct {
|
||||
Port string // this engine's HTTP port
|
||||
NemoPort string // the warm NeMo-Speech server's port (loopback only)
|
||||
NemoBin string // path to the nemo-speech executable
|
||||
Model string // local GGUF path — MUST exist (see the no-network note above)
|
||||
Language string // default language code
|
||||
EndpointMs int // silence that ends an utterance
|
||||
}
|
||||
|
||||
func envOr(k, d string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
home, _ := os.UserHomeDir()
|
||||
base := filepath.Join(home, ".local", "share", "rudder")
|
||||
return config{
|
||||
Port: envOr("RUDDER_PORT", "5193"),
|
||||
NemoPort: envOr("RUDDER_NEMO_PORT", "5194"),
|
||||
NemoBin: envOr("RUDDER_NEMO_BIN", filepath.Join(base, "bin", "nemo-speech-0.1.0-linux-x86_64-cpu", "bin", "nemo-speech")),
|
||||
Model: envOr("RUDDER_MODEL", filepath.Join(base, "models", "nemotron-3.5-asr-streaming-0.6b.q8_0.gguf")),
|
||||
Language: envOr("RUDDER_LANG", "en-US"),
|
||||
EndpointMs: 800,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Log ring: every line is also served at /api/rudder/log so the Debug panel can copy it ──────
|
||||
|
||||
type ring struct {
|
||||
mu sync.Mutex
|
||||
lines []string
|
||||
}
|
||||
|
||||
// ★ PRIVACY: the log is what people paste to an agent (🐞 Debug → copy report), so it must never carry
|
||||
// anything that identifies them. Every line is redacted as it is written: device MAC addresses (inside
|
||||
// Bluetooth device names like bluez_input.AA_BB_…) and the home folder (which contains the username).
|
||||
// Transcribed words are never logged at all.
|
||||
var (
|
||||
macRe = regexp.MustCompile(`(?i)\b([0-9a-f]{2}[:_]){5}[0-9a-f]{2}\b`)
|
||||
homeDir = func() string { h, _ := os.UserHomeDir(); return h }()
|
||||
)
|
||||
|
||||
func redact(s string) string {
|
||||
s = macRe.ReplaceAllString(s, "XX:XX:XX:XX:XX:XX")
|
||||
if homeDir != "" && homeDir != "/" {
|
||||
s = strings.ReplaceAll(s, homeDir, "~")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (r *ring) add(format string, a ...any) {
|
||||
line := redact(time.Now().Format("15:04:05.000") + " " + fmt.Sprintf(format, a...))
|
||||
log.Println(line)
|
||||
r.mu.Lock()
|
||||
r.lines = append(r.lines, line)
|
||||
if len(r.lines) > 3000 {
|
||||
r.lines = r.lines[len(r.lines)-3000:]
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *ring) text() string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return strings.Join(r.lines, "\n")
|
||||
}
|
||||
|
||||
// ─── Event hub: Server-Sent Events to every open plugin ─────────────────────────────────────────
|
||||
|
||||
type event map[string]any
|
||||
|
||||
type hub struct {
|
||||
mu sync.Mutex
|
||||
clients map[chan []byte]struct{}
|
||||
}
|
||||
|
||||
func (h *hub) subscribe() chan []byte {
|
||||
c := make(chan []byte, 64)
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return c
|
||||
}
|
||||
|
||||
func (h *hub) unsubscribe(c chan []byte) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, c)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *hub) send(e event) {
|
||||
b, _ := json.Marshal(e)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for c := range h.clients {
|
||||
select {
|
||||
case c <- b:
|
||||
default: // a slow client drops an event rather than stalling the audio path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Engine: supervises nemo-speech serve and one recording session at a time ───────────────────
|
||||
|
||||
type engine struct {
|
||||
cfg config
|
||||
logs *ring
|
||||
hub *hub
|
||||
|
||||
mu sync.Mutex
|
||||
state string // starting | ready | error
|
||||
stateErr string
|
||||
nemo *exec.Cmd
|
||||
recording bool
|
||||
recorder string
|
||||
language string
|
||||
stopSession func()
|
||||
device string
|
||||
devs *deviceWatcher
|
||||
nemoKey string
|
||||
}
|
||||
|
||||
func (e *engine) setState(s, errText string) {
|
||||
e.mu.Lock()
|
||||
e.state, e.stateErr = s, errText
|
||||
e.mu.Unlock()
|
||||
e.hub.send(event{"type": "status", "engine": s, "error": errText})
|
||||
}
|
||||
|
||||
func (e *engine) status() event {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return event{
|
||||
"engine": e.state, "error": e.stateErr, "recording": e.recording, "recorder": e.recorder,
|
||||
"language": e.language, "defaultLanguage": e.cfg.Language, "device": e.device,
|
||||
"runtime": "NVIDIA NeMo-Speech.cpp (ggml, CPU)", "model": filepath.Base(e.cfg.Model),
|
||||
}
|
||||
}
|
||||
|
||||
// startNemo launches the warm speech server. The model loads and warms up once (~20 s on CPU),
|
||||
// so starting and stopping a recording is instant afterwards.
|
||||
func (e *engine) startNemo() {
|
||||
if st, err := os.Stat(e.cfg.Model); err != nil || st.IsDir() {
|
||||
e.setState("error", "model file not found: "+e.cfg.Model+" (refusing to start: a non-local model would make the runtime download it)")
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(e.cfg.NemoBin); err != nil {
|
||||
e.setState("error", "nemo-speech runtime not found: "+e.cfg.NemoBin)
|
||||
return
|
||||
}
|
||||
e.setState("starting", "")
|
||||
// A fresh random key each start: the speech server answers only this engine, not any other local
|
||||
// program or web page that finds its port.
|
||||
cmd := exec.Command(e.cfg.NemoBin, "serve",
|
||||
"--host", "127.0.0.1", "--port", e.cfg.NemoPort,
|
||||
"--asr-model", e.cfg.Model, "--device", "cpu", "--no-ui", "--api-key", e.nemoKey)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL} // dies with us
|
||||
out, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = cmd.Stdout
|
||||
if err := cmd.Start(); err != nil {
|
||||
e.setState("error", "could not start nemo-speech: "+err.Error())
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.nemo = cmd
|
||||
e.mu.Unlock()
|
||||
e.logs.add("nemo-speech serve started (pid %d) on 127.0.0.1:%s", cmd.Process.Pid, e.cfg.NemoPort)
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := out.Read(buf)
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(buf[:n])), "\n") {
|
||||
if l != "" {
|
||||
e.logs.add("[nemo] %s", l)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
e.setState("error", fmt.Sprintf("nemo-speech exited: %v", err))
|
||||
}()
|
||||
// Ready = the model list answers.
|
||||
go func() {
|
||||
url := "http://127.0.0.1:" + e.cfg.NemoPort + "/v1/models"
|
||||
for i := 0; i < 600; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+e.nemoKey)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
e.logs.add("nemo-speech ready after %.1fs", float64(i+1)*0.5)
|
||||
e.setState("ready", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
e.mu.Lock()
|
||||
s := e.state
|
||||
e.mu.Unlock()
|
||||
if s == "error" {
|
||||
return
|
||||
}
|
||||
}
|
||||
e.setState("error", "nemo-speech did not become ready within 5 minutes")
|
||||
}()
|
||||
}
|
||||
|
||||
// recorderCmds: raw s16le 16 kHz mono PCM on stdout. parec first — on PipeWire/Bluetooth desktops it
|
||||
// talks to the sound server directly (the old Rudder's hardest-won lesson: PortAudio/ALSA-only
|
||||
// capture stalls on Bluetooth headsets).
|
||||
// RUDDER_RECORDER overrides the list with one space-separated command (e.g. a test that replays a
|
||||
// PCM file), so the engine can be tested end to end without a microphone.
|
||||
// device is a sound-server source name from /api/rudder/devices; empty = the system default input.
|
||||
func recorderCmds(device string) [][]string {
|
||||
if v := os.Getenv("RUDDER_RECORDER"); v != "" {
|
||||
return [][]string{strings.Fields(v)}
|
||||
}
|
||||
parec := []string{"parec", "--format=s16le", "--rate=16000", "--channels=1", "--raw", "--latency-msec=100"}
|
||||
pw := []string{"pw-record", "--format=s16", "--rate=16000", "--channels=1"}
|
||||
if device != "" {
|
||||
parec = append(parec, "--device="+device)
|
||||
pw = append(pw, "--target="+device)
|
||||
}
|
||||
pw = append(pw, "-")
|
||||
cmds := [][]string{parec, pw}
|
||||
if device == "" { // arecord can't address a sound-server source by name
|
||||
cmds = append(cmds, []string{"arecord", "-q", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw"})
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (e *engine) start(lang, device string) error {
|
||||
e.mu.Lock()
|
||||
if e.state != "ready" {
|
||||
e.mu.Unlock()
|
||||
return fmt.Errorf("engine is %s — wait for it to be ready", e.state)
|
||||
}
|
||||
if e.recording {
|
||||
e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if lang == "" {
|
||||
lang = e.cfg.Language
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
ws, _, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:"+e.cfg.NemoPort+"/v1/audio/transcriptions/realtime",
|
||||
http.Header{"Authorization": {"Bearer " + e.nemoKey}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to speech server: %w", err)
|
||||
}
|
||||
upd, _ := json.Marshal(event{"type": "session.update", "session": event{
|
||||
"sample_rate": 16000, "language": lang, "automatic_punctuation": true, "endpointing_ms": e.cfg.EndpointMs,
|
||||
}})
|
||||
if err := ws.WriteMessage(websocket.TextMessage, upd); err != nil {
|
||||
ws.Close()
|
||||
return fmt.Errorf("session.update: %w", err)
|
||||
}
|
||||
|
||||
// Bluetooth headsets start on CVSD, not mSBC. Measured 2026-09-27 on a Bluetooth headset: on mSBC the
|
||||
// mic link died 3-5 s into EVERY session that had speech (kernel: corrupted SCO packets); after
|
||||
// switching to CVSD it ran 9.5 minutes without a drop. CVSD is 8 kHz, and Nemotron's transcript of an
|
||||
// 8 kHz copy of the test clip differed by one word. RUDDER_KEEP_MSBC=1 opts out.
|
||||
if card := btCard(device); card != "" && os.Getenv("RUDDER_KEEP_MSBC") == "" {
|
||||
if p := currentProfile(card); p == "headset-head-unit-msbc" {
|
||||
e.logs.add("bluetooth headset on mSBC — switching to CVSD before recording (stable voice link)")
|
||||
e.hub.send(event{"type": "recovering", "message": "Switching your Bluetooth headset to its stable voice mode…"})
|
||||
if err := switchProfile(card, e.logs); err != nil {
|
||||
e.logs.add("pre-switch to CVSD failed: %v", err)
|
||||
} else {
|
||||
e.hub.send(event{"type": "recovered", "message": "Headset is in its stable voice mode — go ahead and talk."})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
first, err := spawnRecorder(device, e.logs)
|
||||
if err != nil {
|
||||
ws.Close()
|
||||
return err
|
||||
}
|
||||
recName := first.name
|
||||
var recMu sync.Mutex
|
||||
cur := first // the live recorder; replaced when a dead mic link is recovered
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var writeMu sync.Mutex
|
||||
done := make(chan struct{})
|
||||
|
||||
e.mu.Lock()
|
||||
e.recording, e.recorder, e.language, e.device = true, recName, lang, device
|
||||
e.stopSession = func() {
|
||||
cancel()
|
||||
recMu.Lock()
|
||||
cur.kill()
|
||||
recMu.Unlock()
|
||||
writeMu.Lock()
|
||||
_ = ws.WriteMessage(websocket.TextMessage, []byte(`{"type":"input_audio_buffer.commit"}`))
|
||||
writeMu.Unlock()
|
||||
select { // let the final words arrive
|
||||
case <-done:
|
||||
case <-time.After(4 * time.Second):
|
||||
}
|
||||
ws.Close()
|
||||
}
|
||||
e.mu.Unlock()
|
||||
e.logs.add("recording started: recorder=%s device=%q language=%s profile=%s", recName, device, lang, currentProfile(btCard(device)))
|
||||
go watchKernelBluetooth(ctx, e.logs)
|
||||
e.hub.send(event{"type": "recording", "recording": true, "recorder": recName, "language": lang, "device": device})
|
||||
for _, d := range e.devs.current().Devices {
|
||||
if (d.ID == device || (device == "" && d.Default)) && d.Muted {
|
||||
e.hub.send(event{"type": "warning", "message": "Your microphone \"" + d.Label + "\" is muted in the system sound settings — unmute it or nothing will be heard."})
|
||||
}
|
||||
}
|
||||
|
||||
// Audio pump: 100 ms frames → speech server, plus a level meter and dead-mic RECOVERY.
|
||||
go func() {
|
||||
frame := make([]byte, 3200)
|
||||
var silentSince time.Time
|
||||
recoveries := 0
|
||||
var recovering atomic.Bool
|
||||
var resetting atomic.Bool
|
||||
tracePeak, lastTrace := 0, time.Now()
|
||||
var healthySince time.Time // a mic that stays healthy 30 s earns back its 3 reconnects
|
||||
gaveUp := false
|
||||
lastLevel := time.Time{}
|
||||
for {
|
||||
recMu.Lock()
|
||||
r := cur
|
||||
recMu.Unlock()
|
||||
if _, err := io.ReadFull(r.out, frame); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
recMu.Lock()
|
||||
swapped := cur != r
|
||||
recMu.Unlock()
|
||||
if swapped { // we killed it ourselves during a recovery: read from the new one
|
||||
continue
|
||||
}
|
||||
// A headset reset restarts PipeWire, which kills parec mid-read. That is expected:
|
||||
// wait for the reset to hand over a fresh recorder instead of ending the session.
|
||||
if resetting.Load() {
|
||||
for resetting.Load() && ctx.Err() == nil {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// The recorder died on its own (bad device, sound server unreachable, device unplugged).
|
||||
// End the session so the UI stops showing "Listening", and say WHY in plain words.
|
||||
_ = r.cmd.Wait()
|
||||
why := strings.TrimSpace(r.errBuf.String())
|
||||
e.logs.add("recorder %s ended unexpectedly: %v | stderr: %s", r.name, err, why)
|
||||
msg := "The microphone stopped delivering audio."
|
||||
switch {
|
||||
case strings.Contains(why, "Connection refused") || strings.Contains(why, "pa_context_connect"):
|
||||
msg = "Can't reach the computer's sound system (PipeWire/PulseAudio). Is it running?"
|
||||
case strings.Contains(why, "No such entity") || strings.Contains(why, "not found"):
|
||||
msg = "That microphone isn't available any more — it may have been unplugged or disconnected. Pick another one."
|
||||
}
|
||||
if why != "" {
|
||||
msg += " (" + r.name + ": " + why + ")"
|
||||
}
|
||||
e.hub.send(event{"type": "error", "message": msg})
|
||||
go e.stop()
|
||||
return
|
||||
}
|
||||
peak := 0
|
||||
for i := 0; i+1 < len(frame); i += 2 {
|
||||
v := int(int16(binary.LittleEndian.Uint16(frame[i:])))
|
||||
if v < 0 {
|
||||
v = -v
|
||||
}
|
||||
if v > peak {
|
||||
peak = v
|
||||
}
|
||||
}
|
||||
// ★ DEAD MIC LINK. Exact-zero samples are not quiet speech (a live mic always has a noise
|
||||
// floor). Measured 2026-09-27 on a Bluetooth headset (HFP, mSBC): four words
|
||||
// transcribed, then literal zeros while parec stayed "healthy" — the old Rudder's BUG 1,
|
||||
// the headset losing the mic side of the link. Re-opening the source restarts the audio
|
||||
// stream; the speech session and the transcript carry on untouched.
|
||||
if peak == 0 {
|
||||
if silentSince.IsZero() {
|
||||
silentSince = time.Now()
|
||||
} else if !gaveUp && !resetting.Load() && time.Since(silentSince) > 1500*time.Millisecond {
|
||||
recoveries++
|
||||
switch {
|
||||
case recoveries == 1:
|
||||
// Step 1: reopen the recorder (cheap; fixes a stalled stream).
|
||||
e.logs.add("mic link dead (exact zeros 1.5s) — step 1: reopening the microphone")
|
||||
e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Microphone went silent — reconnecting it…"})
|
||||
if next, err := spawnRecorder(device, e.logs); err == nil {
|
||||
recMu.Lock()
|
||||
old := cur
|
||||
cur = next
|
||||
recMu.Unlock()
|
||||
old.kill()
|
||||
recovering.Store(true)
|
||||
}
|
||||
case recoveries == 2 && btCard(device) != "":
|
||||
// Step 2: force a NEW Bluetooth voice link by switching the headset's voice profile to
|
||||
// CVSD (the old Rudder's proven-stabler choice). Unlike a detect-first reset script this does not
|
||||
// "detect first", so it can't decide a dead link is fine.
|
||||
card := btCard(device)
|
||||
e.logs.add("mic still dead — step 2: forcing a new voice link: %s → headset-head-unit-cvsd", card)
|
||||
e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Microphone still silent — switching your Bluetooth headset to its more stable voice mode…"})
|
||||
resetting.Store(true)
|
||||
go func() {
|
||||
defer resetting.Store(false)
|
||||
if err := switchProfile(card, e.logs); err != nil {
|
||||
e.hub.send(event{"type": "warning", "message": "Couldn't switch the headset's voice mode: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if next, err := spawnRecorder(device, e.logs); err == nil {
|
||||
recMu.Lock()
|
||||
old := cur
|
||||
cur = next
|
||||
recMu.Unlock()
|
||||
old.kill()
|
||||
recovering.Store(true)
|
||||
}
|
||||
}()
|
||||
case recoveries == 3 && btResetPath() != "":
|
||||
// Step 3: full headset reset (disconnect → restart PipeWire → reconnect).
|
||||
e.logs.add("mic still dead — step 3: full headset reset via RUDDER_BT_RESET")
|
||||
e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Still silent — fully resetting your Bluetooth headset (about 15 seconds)…"})
|
||||
resetting.Store(true)
|
||||
go func() {
|
||||
defer resetting.Store(false)
|
||||
ok, ran := runHeadsetReset(e.logs)
|
||||
if !ran {
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
e.hub.send(event{"type": "warning", "message": "The headset reset didn't bring the mic back — is the headset on and in range?"})
|
||||
}
|
||||
if next, err := spawnRecorder(device, e.logs); err == nil {
|
||||
recMu.Lock()
|
||||
old := cur
|
||||
cur = next
|
||||
recMu.Unlock()
|
||||
old.kill()
|
||||
recovering.Store(true)
|
||||
}
|
||||
}()
|
||||
default:
|
||||
gaveUp = true
|
||||
e.logs.add("mic still dead after reopen + headset resets — giving up for this session")
|
||||
msg := "Your microphone keeps going silent. This is the Bluetooth headset dropping its mic link (the kernel reports corrupted voice packets), not Rudder. Try a wired or built-in mic, or turn off the headset's noise cancellation / adaptive audio in its app."
|
||||
if btResetPath() == "" {
|
||||
msg += " (No headset-reset command is configured — see RUDDER_BT_RESET.)"
|
||||
}
|
||||
e.hub.send(event{"type": "warning", "message": msg})
|
||||
}
|
||||
silentSince = time.Now()
|
||||
}
|
||||
} else {
|
||||
if recovering.Load() {
|
||||
recovering.Store(false)
|
||||
healthySince = time.Now()
|
||||
e.logs.add("mic recovered after %d reconnect(s)", recoveries)
|
||||
e.hub.send(event{"type": "recovered", "message": "Microphone is back — keep talking."})
|
||||
}
|
||||
if recoveries > 0 && !healthySince.IsZero() && time.Since(healthySince) > 30*time.Second {
|
||||
recoveries, gaveUp, healthySince = 0, false, time.Time{}
|
||||
}
|
||||
silentSince = time.Time{}
|
||||
}
|
||||
// Flight recorder: the mic level twice a second, so a drop can be lined up against kernel and
|
||||
// sound-server events in the same log (/api/rudder/log).
|
||||
tracePeak = max(tracePeak, peak)
|
||||
if time.Since(lastTrace) >= 500*time.Millisecond {
|
||||
e.logs.add("trace level peak=%d", tracePeak)
|
||||
tracePeak, lastTrace = 0, time.Now()
|
||||
}
|
||||
if time.Since(lastLevel) > 150*time.Millisecond {
|
||||
lastLevel = time.Now()
|
||||
e.hub.send(event{"type": "level", "peak": float64(peak) / 32768})
|
||||
}
|
||||
writeMu.Lock()
|
||||
err := ws.WriteMessage(websocket.BinaryMessage, frame)
|
||||
writeMu.Unlock()
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
e.hub.send(event{"type": "error", "message": "speech server connection lost: " + err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Result reader: map the server's events onto the plugin's small vocabulary.
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
_, msg, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(msg, &m) != nil {
|
||||
continue
|
||||
}
|
||||
t, _ := m["type"].(string)
|
||||
text := firstString(m, "transcript", "delta", "text")
|
||||
switch {
|
||||
case strings.HasSuffix(t, "input_audio_transcription.delta"):
|
||||
// Deltas APPEND to the utterance in progress; the server sends many empty ones.
|
||||
if text != "" {
|
||||
e.hub.send(event{"type": "partial", "text": text})
|
||||
}
|
||||
case strings.HasSuffix(t, "input_audio_transcription.completed"):
|
||||
e.hub.send(event{"type": "final", "text": text, "at": time.Now().Format(time.RFC3339)})
|
||||
case t == "error":
|
||||
e.logs.add("speech server error: %s", string(msg))
|
||||
e.hub.send(event{"type": "error", "message": string(msg)})
|
||||
case t == "input_audio_buffer.committed":
|
||||
return
|
||||
default:
|
||||
e.logs.add("speech event: %s", t)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// recorderProc is one running recorder process.
|
||||
type recorderProc struct {
|
||||
cmd *exec.Cmd
|
||||
out io.ReadCloser
|
||||
name string
|
||||
errBuf *bytes.Buffer
|
||||
}
|
||||
|
||||
func (r *recorderProc) kill() {
|
||||
if r != nil && r.cmd != nil && r.cmd.Process != nil {
|
||||
_ = r.cmd.Process.Kill()
|
||||
go func() { _ = r.cmd.Wait() }()
|
||||
}
|
||||
}
|
||||
|
||||
// spawnRecorder starts the first recorder that works for this device.
|
||||
func spawnRecorder(device string, logs *ring) (*recorderProc, error) {
|
||||
for _, c := range recorderCmds(device) {
|
||||
if _, err := exec.LookPath(c[0]); err != nil {
|
||||
continue
|
||||
}
|
||||
cmd := exec.Command(c[0], c[1:]...)
|
||||
cmd.Env = audioEnv()
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
|
||||
errBuf := &bytes.Buffer{}
|
||||
cmd.Stderr = errBuf
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
logs.add("recorder %s failed to start: %v", c[0], err)
|
||||
continue
|
||||
}
|
||||
return &recorderProc{cmd: cmd, out: out, name: c[0], errBuf: errBuf}, nil
|
||||
}
|
||||
return nil, errors.New("no microphone recorder found (need parec, pw-record or arecord)")
|
||||
}
|
||||
|
||||
func firstString(m map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if s, ok := m[k].(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if item, ok := m["item"].(map[string]any); ok {
|
||||
return firstString(item, keys...)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *engine) stop() {
|
||||
e.mu.Lock()
|
||||
stop := e.stopSession
|
||||
e.stopSession = nil
|
||||
was := e.recording
|
||||
e.recording = false
|
||||
e.mu.Unlock()
|
||||
if !was || stop == nil {
|
||||
return
|
||||
}
|
||||
stop()
|
||||
e.logs.add("recording stopped")
|
||||
e.hub.send(event{"type": "recording", "recording": false})
|
||||
}
|
||||
|
||||
func (e *engine) shutdown() {
|
||||
e.stop()
|
||||
e.mu.Lock()
|
||||
n := e.nemo
|
||||
e.mu.Unlock()
|
||||
if n != nil && n.Process != nil {
|
||||
_ = n.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP API ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// guard: two protections for a server that streams what you say.
|
||||
// - Host check (DNS rebinding): a web page on any domain can make the browser resolve its own hostname
|
||||
// to 127.0.0.1 and then read this API as "same-origin". Only requests addressed to 127.0.0.1 or
|
||||
// localhost are served.
|
||||
// - CORS: only the desktop webview's origin (tauri://localhost, or http://tauri.localhost on Windows)
|
||||
// may read responses cross-origin. The Plugin Studio needs none: it reaches /api/rudder through the
|
||||
// dev server's same-origin proxy. RUDDER_ALLOW_ORIGIN adds one more exact origin (tests).
|
||||
func guard(next http.Handler) http.Handler {
|
||||
extra := os.Getenv("RUDDER_ALLOW_ORIGIN")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
if i := strings.LastIndex(host, ":"); i >= 0 && !strings.HasSuffix(host, "]") {
|
||||
host = host[:i]
|
||||
}
|
||||
if host != "127.0.0.1" && host != "localhost" && host != "[::1]" {
|
||||
http.Error(w, "forbidden host", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
o := r.Header.Get("Origin")
|
||||
if o == "tauri://localhost" || o == "http://tauri.localhost" || (extra != "" && o == extra) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", o)
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Vary", "Origin")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
logs := &ring{}
|
||||
h := &hub{clients: map[chan []byte]struct{}{}}
|
||||
key := make([]byte, 24)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
log.Fatal("no randomness for the speech-server key: ", err)
|
||||
}
|
||||
e := &engine{cfg: cfg, logs: logs, hub: h, state: "starting", devs: &deviceWatcher{hub: h, logs: logs}, nemoKey: hex.EncodeToString(key)}
|
||||
e.devs.run()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/rudder/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, e.status())
|
||||
})
|
||||
mux.HandleFunc("/api/rudder/start", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, 405, event{"error": "POST only"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Language string `json:"language"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if err := e.start(body.Language, body.Device); err != nil {
|
||||
e.logs.add("start failed: %v", err)
|
||||
writeJSON(w, 409, event{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, e.status())
|
||||
})
|
||||
mux.HandleFunc("/api/rudder/stop", func(w http.ResponseWriter, r *http.Request) {
|
||||
e.stop()
|
||||
writeJSON(w, 200, e.status())
|
||||
})
|
||||
mux.HandleFunc("/api/rudder/devices", func(w http.ResponseWriter, r *http.Request) {
|
||||
e.devs.refresh("request")
|
||||
writeJSON(w, 200, e.devs.current())
|
||||
})
|
||||
mux.HandleFunc("/api/rudder/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Device string `json:"device"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
writeJSON(w, 200, e.micTest(body.Device))
|
||||
})
|
||||
mux.HandleFunc("/api/rudder/log", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = io.WriteString(w, logs.text())
|
||||
})
|
||||
mux.HandleFunc("/api/rudder/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
fl, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
c := e.hub.subscribe()
|
||||
defer e.hub.unsubscribe(c)
|
||||
first, _ := json.Marshal(event{"type": "status", "status": e.status()})
|
||||
fmt.Fprintf(w, "data: %s\n\n", first)
|
||||
devs, _ := json.Marshal(event{"type": "devices", "devices": e.devs.current()})
|
||||
fmt.Fprintf(w, "data: %s\n\n", devs)
|
||||
fl.Flush()
|
||||
ping := time.NewTicker(15 * time.Second)
|
||||
defer ping.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case b := <-c:
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
fl.Flush()
|
||||
case <-ping.C:
|
||||
fmt.Fprint(w, ": ping\n\n")
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
srv := &http.Server{Addr: "127.0.0.1:" + cfg.Port, Handler: guard(mux)}
|
||||
logs.add("rudder-engine listening on http://127.0.0.1:%s (runtime=%s model=%s)", cfg.Port, cfg.NemoBin, cfg.Model)
|
||||
e.startNemo()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sig
|
||||
e.shutdown()
|
||||
os.Exit(0)
|
||||
}()
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
e.shutdown()
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
59
backend/rudder-engine/setup-runtime.sh
Executable file
59
backend/rudder-engine/setup-runtime.sh
Executable file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bash
|
||||
# setup-runtime.sh — fetch Rudder's speech runtime + model, verified by SHA-256, then build the engine.
|
||||
#
|
||||
# Rudder (plugin-designs/rudder-voice) transcribes on THIS computer. It needs two things that are too big
|
||||
# for git: NVIDIA's NeMo-Speech.cpp runtime (~4 MB, CPU build) and the Nemotron 3.5 ASR Streaming GGUF
|
||||
# (~742 MB). Both are pinned by version and checksum; a mismatch aborts instead of running an unverified
|
||||
# file. After this, `bun run dev` starts the engine and Rudder appears in the Plugin Studio.
|
||||
#
|
||||
# bash backend/rudder-engine/setup-runtime.sh # installs into ~/.local/share/rudder
|
||||
# RUDDER_HOME=/some/dir bash backend/rudder-engine/setup-runtime.sh
|
||||
#
|
||||
# Linux x86_64 only for now (the runtime ships other platforms; add them when a desktop build needs them).
|
||||
set -euo pipefail
|
||||
|
||||
RUDDER_HOME="${RUDDER_HOME:-$HOME/.local/share/rudder}"
|
||||
RT_VER="0.1.0"
|
||||
RT_NAME="nemo-speech-${RT_VER}-linux-x86_64-cpu"
|
||||
RT_URL="https://github.com/NVIDIA/NeMo-Speech.cpp/releases/download/v${RT_VER}/${RT_NAME}.tar.gz"
|
||||
RT_SHA="0f74131d631ad2c694cf0ec53490866bb6461147959589a69fb6fc231944065b"
|
||||
# NVIDIA re-converted the GGUF on 2026-09-10 (embedded tokenizer); this revision is the one Rudder is tested with.
|
||||
MODEL_REV="ea30d66debe3"
|
||||
MODEL_FILE="nemotron-3.5-asr-streaming-0.6b.q8_0.gguf"
|
||||
MODEL_URL="https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b/resolve/${MODEL_REV}/${MODEL_FILE}"
|
||||
MODEL_SHA="3fc991d3badad7277c11030a7519832cddaf2057aafed6d4b25147e953a070b1"
|
||||
|
||||
say() { printf '\033[1;34m→\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ "$(uname -s)-$(uname -m)" = "Linux-x86_64" ] || die "This script currently supports Linux x86_64 only."
|
||||
command -v curl >/dev/null || die "curl is required."
|
||||
command -v sha256sum >/dev/null || die "sha256sum is required."
|
||||
|
||||
fetch() { # url dest sha
|
||||
local url="$1" dest="$2" sha="$3"
|
||||
if [ -f "$dest" ] && [ "$(sha256sum "$dest" | cut -d' ' -f1)" = "$sha" ]; then
|
||||
say "already have $(basename "$dest") (checksum OK)"; return
|
||||
fi
|
||||
say "downloading $(basename "$dest")"
|
||||
curl -fL --retry 3 -C - -o "$dest.part" "$url"
|
||||
[ "$(sha256sum "$dest.part" | cut -d' ' -f1)" = "$sha" ] || { rm -f "$dest.part"; die "checksum mismatch for $(basename "$dest") — refusing to use it"; }
|
||||
mv "$dest.part" "$dest"
|
||||
}
|
||||
|
||||
mkdir -p "$RUDDER_HOME/bin" "$RUDDER_HOME/models"
|
||||
fetch "$RT_URL" "$RUDDER_HOME/bin/$RT_NAME.tar.gz" "$RT_SHA"
|
||||
tar -xzf "$RUDDER_HOME/bin/$RT_NAME.tar.gz" -C "$RUDDER_HOME/bin"
|
||||
fetch "$MODEL_URL" "$RUDDER_HOME/models/$MODEL_FILE" "$MODEL_SHA"
|
||||
|
||||
if command -v go >/dev/null; then
|
||||
say "building backend/bin/rudder-engine"
|
||||
(cd "$(dirname "$0")" && go build -o ../bin/rudder-engine .)
|
||||
else
|
||||
printf '! Go not found — install Go, then: cd backend/rudder-engine && go build -o ../bin/rudder-engine .\n'
|
||||
fi
|
||||
|
||||
for tool in parec pactl; do command -v "$tool" >/dev/null || printf '! %s not found — install pipewire-pulse or pulseaudio-utils for microphone capture.\n' "$tool"; done
|
||||
|
||||
say "done. Runtime: $RUDDER_HOME/bin/$RT_NAME Model: $RUDDER_HOME/models/$MODEL_FILE"
|
||||
say "run \`bun run dev\` and open Rudder (rudder-voice) in the Plugin Studio."
|
||||
23
data/people-groups/christian-share.json
Normal file
23
data/people-groups/christian-share.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"_what": "Evangelical share of the DOXA people groups' POPULATION, for the packet's life-calendar page.",
|
||||
"_method": "Each group's published EvangelicalLevel BAND is taken at its midpoint (0 for 'No Known Evangelicals'), weighted by the group's Population, and summed. peoplegroups.org publishes a band, never a number, so a percentage requires choosing a value inside each band; the midpoint is stated on the page itself.",
|
||||
"_join": "IMB CSV ROP3Code + ISOAlpha3 against doxa-groups-snapshot.json rop3 + country — the same join the Vision Card uses (build-lists.mjs:13).",
|
||||
"sources": {
|
||||
"imb": "data/people-groups/people_groups.csv",
|
||||
"doxa": "https://pray.doxa.life/api/people-groups/list",
|
||||
"fetched": "2026-09-25"
|
||||
},
|
||||
"groupsInList": 2107,
|
||||
"groupsMatched": 2091,
|
||||
"population": 209079159,
|
||||
"evangelicalPopulation": 1282178,
|
||||
"pctEvangelical": 0.6133,
|
||||
"bands": {
|
||||
"No Known Evangelicals": 1444,
|
||||
"Less than 2%": 638,
|
||||
"2% or Greater but Less than 5%": 6,
|
||||
"5% or Greater but Less than 10%": 1,
|
||||
"10% or Greater but Less than 15%": 1,
|
||||
"50% or Greater but Less than 75%": 1
|
||||
}
|
||||
}
|
||||
50
data/people-groups/scripture-sources.json
Normal file
50
data/people-groups/scripture-sources.json
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"_what": "Provenance for the Mark 5:1-20 passage on packet page 8 (prayer point 4), per locale.",
|
||||
"_rule": "Scripture is NEVER machine-translated and never reproduced from memory. Each text is a published PUBLIC-DOMAIN translation fetched from a citable source that ships licence metadata, stored with the translation's NAME. dx-shepherd END-DXS-MK5GO, 2026-09-27.",
|
||||
"_verified": "Verse numbering checked against the English (World English Bible): all five extract exactly verses 1-20, verse 1 matches 'they came to the other side of the sea, into the country of the Gadarenes' and verse 20 ends at 'Decapolis ... everyone marveled'.",
|
||||
"_api": "https://bible.helloao.org/api/<id>/MRK/5.json",
|
||||
"locales": {
|
||||
"ar": {
|
||||
"translationId": "arb_vdv",
|
||||
"name": "Arabic Van Dyck Bible",
|
||||
"licenseUrl": "https://ebible.org/Scriptures/details.php?id=arb-vd",
|
||||
"website": "https://ebible.org/Scriptures/details.php?id=arb-vd",
|
||||
"sha256": "a7dbde0da2118c438758bca1706ea71c5c2e4cec72eb996463e4c761c9f6bffb",
|
||||
"verses": "1-20"
|
||||
},
|
||||
"ru": {
|
||||
"translationId": "rus_syn",
|
||||
"name": "Russian Synodal Bible",
|
||||
"licenseUrl": "https://ebible.org/Scriptures/details.php?id=russyn",
|
||||
"website": "https://ebible.org/Scriptures/details.php?id=russyn",
|
||||
"sha256": "0ae481d3257ba895a57748179fc194012ffbfd500c141f9c3ef042a4db4771c6",
|
||||
"verses": "1-20"
|
||||
},
|
||||
"es": {
|
||||
"translationId": "spa_r09",
|
||||
"name": "Reina Valera 1909",
|
||||
"licenseUrl": "https://ebible.org/Scriptures/details.php?id=spaRV1909",
|
||||
"website": "https://ebible.org/Scriptures/details.php?id=spaRV1909",
|
||||
"sha256": "94e154b2e6e56eda1702d9e9f664357a5f2aa82634b551111b0b698d124e97d5",
|
||||
"verses": "1-20"
|
||||
},
|
||||
"de": {
|
||||
"translationId": "deu_l12",
|
||||
"name": "German Luther Bible 1912",
|
||||
"licenseUrl": "https://ebible.org/Scriptures/details.php?id=deu1912",
|
||||
"website": "https://ebible.org/Scriptures/details.php?id=deu1912",
|
||||
"sha256": "ab4c66104466c4978b1b2f9dcea944a5921959f5d38e467239e6757399eda6c6",
|
||||
"verses": "1-20"
|
||||
},
|
||||
"fr": {
|
||||
"translationId": "fra_ost",
|
||||
"name": "French Ostervald Bible",
|
||||
"licenseUrl": "https://ebible.org/Scriptures/details.php?id=fra_fob",
|
||||
"website": "https://ebible.org/Scriptures/details.php?id=fra_fob",
|
||||
"sha256": "98654be2daa22f07286bd4d835748c144af5801ffbd7190ecc56acc414161a97",
|
||||
"verses": "1-20"
|
||||
}
|
||||
},
|
||||
"_english": "en uses the World English Bible (public domain), unchanged.",
|
||||
"_absent": "hi and zh stay ENGLISH and remain in check-packet-strings-parity's DECLARED_ABSENT: PD candidates exist (Hindi 1851 Serampore-lineage; Chinese Union Version 1919) but their licence status varies by jurisdiction and I could not confirm it. pt/it/ro also still English — pt Almeida 1819, it Diodati 1649, ro Cornilescu 1924 are candidates, Cornilescu the least certain."
|
||||
}
|
||||
1
data/people-groups/zoning-records.json
Normal file
1
data/people-groups/zoning-records.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
data/tables/near-neighbors.json
Normal file
1
data/tables/near-neighbors.json
Normal file
File diff suppressed because one or more lines are too long
21
deployment/cloud/Dockerfile
Normal file
21
deployment/cloud/Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# dx-maps cloud dev server: one shared Vite dev server with hot reload for a whole team.
|
||||
# This image is only a bootstrap: at start it clones the repo and runs deployment/cloud/entry.sh
|
||||
# FROM THE REPO, so the recipe lives in git and updates with it. Paste this file as-is into a
|
||||
# container host (Coolify "Dockerfile" build pack, Railway, or `docker build`).
|
||||
FROM quay.io/oauth2-proxy/oauth2-proxy:v7.15.3 AS oauth2proxy
|
||||
FROM oven/bun:1-debian
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=oauth2proxy /bin/oauth2-proxy /usr/local/bin/oauth2-proxy
|
||||
RUN printf '%s\n' \
|
||||
'#!/bin/bash' \
|
||||
'set -u; export GIT_TERMINAL_PROMPT=0' \
|
||||
'T="${GIT_TOKEN:-${FORGEJO_TOKEN:-}}"' \
|
||||
'if [ -n "$T" ]; then printf "#!/bin/sh\ncase \"\$1\" in *sername*) echo \"\$GIT_TOKEN_EFF\";; *) echo;; esac\n" > /tmp/askpass.sh; chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TOKEN_EFF="$T"; fi' \
|
||||
': "${REPO_URL:?set REPO_URL to the dx-maps git URL}"' \
|
||||
'[ -d /app/repo/.git ] || git clone -q --depth 1 --branch "${BRANCH:-main}" "$REPO_URL" /app/repo || { echo "clone failed"; sleep 60; exit 1; }' \
|
||||
'cp /app/repo/deployment/cloud/entry.sh /tmp/entry.sh && exec bash /tmp/entry.sh' \
|
||||
> /boot.sh && chmod 755 /boot.sh
|
||||
ENV PORT=5173 BRANCH=main PULL_INTERVAL=15
|
||||
EXPOSE 5173
|
||||
CMD ["/boot.sh"]
|
||||
45
deployment/cloud/README.md
Normal file
45
deployment/cloud/README.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Run dx-maps in the cloud: one shared dev server for a team
|
||||
|
||||
One container runs the Plugin Studio with hot reload for everyone. It clones the repo, pulls the
|
||||
branch every few seconds, and Vite hot-reloads whatever changed, so every change an agent lands
|
||||
on `main` shows up in everyone's browser within seconds. No rebuild; `bun run build` is only for
|
||||
publishing.
|
||||
|
||||
With **no OIDC settings** it runs open, like `bun run dev` on a laptop. With **OIDC settings** a
|
||||
sign-in page ("Sign in with *your provider*") sits in front of everything: the Studio, every map,
|
||||
and the hot-reload websocket. Only people in the allowed groups get in.
|
||||
|
||||
## Deploy
|
||||
|
||||
1. Build the image from [`Dockerfile`](./Dockerfile). It's a small bootstrap (Bun, git, and the
|
||||
[oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy) binary); at start it clones the repo
|
||||
and runs [`entry.sh`](./entry.sh) from it, so the recipe updates with the repo. Paste it into any
|
||||
container host (Coolify's "Dockerfile" build pack, Railway, `docker build`).
|
||||
2. Set the environment below and expose the container's `PORT` over HTTPS.
|
||||
3. Give it about 2 GB of memory (Vite plus the dependency install).
|
||||
|
||||
| Variable | Needed | Meaning |
|
||||
|---|---|---|
|
||||
| `REPO_URL` | yes | the dx-maps git URL to clone |
|
||||
| `BRANCH` | no (`main`) | the branch to follow |
|
||||
| `PULL_INTERVAL` | no (`15`) | seconds between pulls |
|
||||
| `GIT_TOKEN` | private repos | a read token for the clone and pulls |
|
||||
| `PORT` | no (`5173`) | the port the container serves on |
|
||||
| `HMR_CLIENT_PORT`, `HMR_PROTOCOL` | behind HTTPS | `443` and `wss`, so browsers reach the hot-reload websocket |
|
||||
|
||||
## Sign-in (optional)
|
||||
|
||||
Set all of these to turn the gate on. Register a **confidential** client at your OIDC provider
|
||||
with the redirect URL `https://<your-host>/oauth2/callback`; PKCE (S256) is used as well.
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `OIDC_ISSUER` | the provider's issuer URL (its `/.well-known/openid-configuration` must resolve) |
|
||||
| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | the registered client |
|
||||
| `OIDC_REDIRECT_URL` | `https://<your-host>/oauth2/callback` |
|
||||
| `OIDC_ALLOWED_GROUPS` | comma-separated groups allowed in; **required** (the server refuses to start without it, because many providers allow open sign-up) |
|
||||
| `OIDC_DISPLAY_NAME` | the name on the button, e.g. "Sign in with *Acme*" |
|
||||
| `OIDC_GROUPS_CLAIM` | the token claim holding groups (default `groups`) |
|
||||
| `OIDC_COOKIE_SECRET` | 32 characters; keeps people signed in across restarts (random if unset) |
|
||||
|
||||
Secrets reach oauth2-proxy through its environment, never its command line.
|
||||
65
deployment/cloud/entry.sh
Executable file
65
deployment/cloud/entry.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/bin/bash
|
||||
# dx-maps cloud dev server, run by the image's /boot.sh from the cloned repo (see Dockerfile).
|
||||
# 1. install, 2. pull BRANCH every PULL_INTERVAL s (Vite hot-reloads what changed),
|
||||
# 3. serve: Vite alone, or behind an OIDC sign-in when OIDC_ISSUER is set.
|
||||
set -u
|
||||
cd /app/repo
|
||||
BRANCH="${BRANCH:-main}"; PORT="${PORT:-5173}"
|
||||
echo "$(date -u +%FT%TZ) START at $(git rev-parse --short HEAD)" | tee -a /tmp/pull.log
|
||||
bun install --frozen-lockfile || bun install
|
||||
|
||||
(
|
||||
while true; do
|
||||
sleep "${PULL_INTERVAL:-15}"
|
||||
OLD=$(git rev-parse HEAD)
|
||||
if git fetch -q --depth 1 origin "$BRANCH"; then
|
||||
NEW=$(git rev-parse FETCH_HEAD)
|
||||
if [ "$OLD" != "$NEW" ]; then
|
||||
git reset -q --hard FETCH_HEAD
|
||||
echo "$(date -u +%FT%TZ) PULLED ${OLD:0:8} -> ${NEW:0:8}" >> /tmp/pull.log
|
||||
fi
|
||||
fi
|
||||
done
|
||||
) &
|
||||
|
||||
CONFIG=/app/repo/deployment/cloud/vite.cloud.config.mjs
|
||||
|
||||
if [ -z "${OIDC_ISSUER:-}" ]; then
|
||||
# Open, like a laptop: no sign-in.
|
||||
export VITE_HOST=0.0.0.0 VITE_PORT="$PORT"
|
||||
exec bun x vite --config "$CONFIG"
|
||||
fi
|
||||
|
||||
# OIDC gate: oauth2-proxy owns the public port; Vite listens on localhost behind it, so the Plugin
|
||||
# Studio, every map and the hot-reload websocket are all behind the same sign-in.
|
||||
: "${OIDC_CLIENT_ID:?}" "${OIDC_CLIENT_SECRET:?}" "${OIDC_REDIRECT_URL:?}"
|
||||
if [ -z "${OIDC_ALLOWED_GROUPS:-}" ]; then
|
||||
echo "refusing to start: set OIDC_ALLOWED_GROUPS (comma-separated). With open sign-up, anyone could log in." >&2
|
||||
exit 1
|
||||
fi
|
||||
ALLOW=(); IFS=',' read -ra G <<< "$OIDC_ALLOWED_GROUPS"
|
||||
for g in "${G[@]}"; do g="${g// /}"; [ -n "$g" ] && ALLOW+=(--allowed-group="$g"); done
|
||||
|
||||
# Secrets reach oauth2-proxy through its environment, never its command line.
|
||||
export OAUTH2_PROXY_CLIENT_SECRET="$OIDC_CLIENT_SECRET"
|
||||
export OAUTH2_PROXY_COOKIE_SECRET="${OIDC_COOKIE_SECRET:-$(head -c 32 /dev/urandom | base64 | tr -d '=+/' | head -c 32)}"
|
||||
|
||||
VITE_HOST=127.0.0.1 VITE_PORT=5174 bun x vite --config "$CONFIG" &
|
||||
oauth2-proxy \
|
||||
--provider=oidc \
|
||||
--oidc-issuer-url="$OIDC_ISSUER" \
|
||||
--client-id="$OIDC_CLIENT_ID" \
|
||||
--redirect-url="$OIDC_REDIRECT_URL" \
|
||||
--code-challenge-method=S256 \
|
||||
--scope="${OIDC_SCOPE:-openid profile email groups}" \
|
||||
--oidc-groups-claim="${OIDC_GROUPS_CLAIM:-groups}" \
|
||||
"${ALLOW[@]}" \
|
||||
--email-domain='*' \
|
||||
--provider-display-name="${OIDC_DISPLAY_NAME:-your identity provider}" \
|
||||
--upstream=http://127.0.0.1:5174/ \
|
||||
--http-address="0.0.0.0:$PORT" \
|
||||
--reverse-proxy=true \
|
||||
--cookie-secure=true \
|
||||
--cookie-samesite=lax &
|
||||
wait -n
|
||||
exit $?
|
||||
22
deployment/cloud/vite.cloud.config.mjs
Normal file
22
deployment/cloud/vite.cloud.config.mjs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Overlay for the cloud dev server: the repo's own vite.config.js plus what a shared, public
|
||||
// host needs. Every value comes from the environment (see deployment/cloud/README.md).
|
||||
import base from '/app/repo/vite.config.js'
|
||||
import { mergeConfig } from 'vite'
|
||||
|
||||
const env = process.env
|
||||
export default async (e) => {
|
||||
const b = typeof base === 'function' ? await base(e) : base
|
||||
const hmr = env.HMR_CLIENT_PORT
|
||||
? { clientPort: Number(env.HMR_CLIENT_PORT), protocol: env.HMR_PROTOCOL || 'wss' }
|
||||
: undefined
|
||||
return mergeConfig(b, {
|
||||
cacheDir: '/tmp/vite-cache',
|
||||
server: {
|
||||
host: env.VITE_HOST || '0.0.0.0',
|
||||
port: Number(env.VITE_PORT || 5173),
|
||||
strictPort: true,
|
||||
allowedHosts: true,
|
||||
...(hmr ? { hmr } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/components/charts/BarChart.vue'`
|
||||
- **ProgressBar** — ProgressBar.vue — the DX-life progress bar: a single-value track, or a segmented one with a legend.
|
||||
`import … from '@map/components/charts/ProgressBar.vue'`
|
||||
- **DashboardBreadcrumb** — ── KIT LANDING 4c-4: MOVED FROM plugin-designs/research-dashboard/ ───────────────────────────
|
||||
`import … from '@map/components/dashboards/DashboardBreadcrumb.vue'`
|
||||
- **DashboardFacetRail** — DashboardFacetRail (was Sidebar.vue) — RDASH redesign phase 2: the floating @map SemanticTreeLegend is DROPPED
|
||||
`import … from '@map/components/dashboards/DashboardFacetRail.vue'`
|
||||
- **DashboardFilterChips** — DashboardFilterChips — the live filterSet rendered as removable AND-stacked chips
|
||||
|
|
@ -133,6 +135,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/useCountryOutline.js'`
|
||||
- **useCsvExport** — useCsvExport.js — ★ SHARED LIBRARY CODE (dashboard-kit landing 3). Two parameters make it
|
||||
`import … from '@map/composables/useCsvExport.js'`
|
||||
- **useDashboardData** — useDashboardData.js — RDASH phase 2: the single in-memory row store (kills the hardcode).
|
||||
`import … from '@map/composables/useDashboardData.js'`
|
||||
- **useDashboardLayout** — useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid.
|
||||
`import … from '@map/composables/useDashboardLayout.js'`
|
||||
- **useDashboardSearch** — useDashboardSearch.js — the ONE free-text search the whole dashboard shares.
|
||||
|
|
@ -212,6 +216,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/utils/countryName.js'`
|
||||
- **embedBase** — embedBase.js — Where THIS bundle's built tree is served from, resolved at runtime.
|
||||
`import … from '@map/utils/embedBase.js'`
|
||||
- **formatCount** — formatCount — the ONE place the fleet decides how a COUNT is punctuated.
|
||||
`import … from '@map/utils/formatCount.js'`
|
||||
- **geoUtils** — geoUtils.js — Geographic / GeoJSON Utility Functions
|
||||
`import … from '@map/utils/geoUtils.js'`
|
||||
- **html** — html.js — the two helpers every HTML string built in library/ must use (CRITIC-3, 2026-09-26).
|
||||
|
|
|
|||
61
internal/backend-dev-plugin.mjs
Normal file
61
internal/backend-dev-plugin.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* internal/backend-dev-plugin.mjs — `bun run dev` starts the local plugin backends.
|
||||
*
|
||||
* WHY: some plugins ship to DESKTOP with a local backend (rudder-voice → backend/bin/rudder-engine,
|
||||
* run as a Tauri sidecar). In the Plugin Studio the same backend must be running, or the plugin can't
|
||||
* be tested in the browser. This plugin starts each built backend with the dev server, forwards its
|
||||
* /api/<name>/ routes, and stops it when the dev server stops. A backend that isn't built is skipped
|
||||
* with a hint: plugins without a backend never notice this file.
|
||||
*
|
||||
* DEV ONLY (`apply: 'serve'`). A production build never starts or bundles a backend.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
// name → how to run it. `route` is proxied to `port` on 127.0.0.1.
|
||||
export const DEV_BACKENDS = [
|
||||
{
|
||||
name: 'rudder-engine',
|
||||
bin: 'backend/bin/rudder-engine',
|
||||
build: 'cd backend/rudder-engine && go build -o ../bin/rudder-engine .',
|
||||
route: '/api/rudder',
|
||||
port: Number(process.env.RUDDER_PORT || 5193),
|
||||
env: { RUDDER_PORT: String(process.env.RUDDER_PORT || 5193), RUDDER_NEMO_PORT: String(process.env.RUDDER_NEMO_PORT || 5194) },
|
||||
},
|
||||
]
|
||||
|
||||
// The proxy table for vite's `server.proxy`. SSE (`/events`) streams through unbuffered.
|
||||
export function devBackendProxy() {
|
||||
return Object.fromEntries(DEV_BACKENDS.map((b) => [b.route, { target: `http://127.0.0.1:${b.port}`, changeOrigin: false }]))
|
||||
}
|
||||
|
||||
export function backendDevPlugin(root) {
|
||||
const children = []
|
||||
return {
|
||||
name: 'dx-backend-dev',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
for (const b of DEV_BACKENDS) {
|
||||
const bin = resolve(root, b.bin)
|
||||
if (!existsSync(bin)) {
|
||||
server.config.logger.warn(`[backend] ${b.name} not built — its plugin will show "engine offline". Build: ${b.build}`)
|
||||
continue
|
||||
}
|
||||
const child = spawn(bin, [], { cwd: root, env: { ...process.env, ...b.env }, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const tag = `[${b.name}]`
|
||||
const out = (d) => String(d).trim().split('\n').forEach((l) => l && server.config.logger.info(`${tag} ${l}`))
|
||||
child.stdout.on('data', out)
|
||||
child.stderr.on('data', out)
|
||||
child.on('exit', (code) => server.config.logger.warn(`${tag} exited (${code})`))
|
||||
children.push(child)
|
||||
server.config.logger.info(`${tag} started → ${b.route} on 127.0.0.1:${b.port}`)
|
||||
}
|
||||
const stop = () => children.forEach((c) => { try { c.kill('SIGTERM') } catch {} })
|
||||
server.httpServer?.on('close', stop)
|
||||
process.once('exit', stop)
|
||||
process.once('SIGINT', () => { stop(); process.exit(0) })
|
||||
process.once('SIGTERM', () => { stop(); process.exit(0) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -74,6 +74,7 @@ for (const b of manifest.bundles || []) {
|
|||
if ((b.profiles || []).some((p) => p.isMap)) mapBundles.add(b.bundle)
|
||||
}
|
||||
const targets = []
|
||||
const mapless = [] // composites that nest no map: reported as N/A, never as unmeasured
|
||||
for (const b of manifest.bundles || []) {
|
||||
// TWO WAYS A COMPOSITE DECLARES ITSELF, and taking only the first under-covered this gate.
|
||||
// `composes` is written when a composite mounts children BY TAG; a composite that BAKES its
|
||||
|
|
@ -84,8 +85,24 @@ for (const b of manifest.bundles || []) {
|
|||
const byCompose = composes.some((c) => mapBundles.has(c))
|
||||
const profile = (b.profiles || []).find((p) => p.element && (byCompose || p.type === 'nested-elements-mfe'))
|
||||
if (!profile || !b.scriptUrl) continue
|
||||
// ★ A COMPOSITE WITH NO MAP IS NOT A MAP-EMBED TARGET, and counting it as one buys a permanent
|
||||
// UNMEASURED that reads like a gap. The `type` arm above is deliberately loose — country-posters-mfe
|
||||
// BAKES its children, so `composes` names nothing — but loose enough to admit composites that nest no
|
||||
// map at all: bp-big-picture composes a research HEADER and a people-groups GRID, and its built
|
||||
// bundle contains zero occurrences of mapbox-gl or maplibre-gl. It can never draw a canvas, so
|
||||
// "no canvas under a minimal config" was the honest answer to a question that should not have been
|
||||
// asked of it. Decided on the BUILT artefact rather than the manifest, because whether a bundle
|
||||
// ships an engine is a fact about what it compiled, not about what it declares.
|
||||
if (!byCompose) {
|
||||
let js = ''
|
||||
try { js = readFileSync(resolve(ROOT, 'public', TREE_NAME, b.bundle, `${b.bundle}.js`), 'utf8') } catch (_) { js = '' }
|
||||
if (js && !/mapbox-gl|maplibre-gl/.test(js)) { mapless.push(`${profile.element} (${b.bundle})`); continue }
|
||||
}
|
||||
targets.push({ bundle: b.bundle, element: profile.element, profile: profile.profile, script: `${b.bundle}/${b.bundle}.js` })
|
||||
}
|
||||
if (mapless.length) {
|
||||
console.log(` · ${mapless.length} composite(s) nest NO map, so the knobs do not apply and they are not targets: ${mapless.join(', ')}`)
|
||||
}
|
||||
if (!targets.length) {
|
||||
console.log('COMPOSITE-EMBED-KNOBS: SKIP — the manifest declares no composite that nests a map.')
|
||||
process.exit(0)
|
||||
|
|
@ -156,7 +173,10 @@ try {
|
|||
// The composite's OWN profile rides with the knob under test (rega, 1a761741: gospel-resources,
|
||||
// my-upg-100-list, regions-dashboard and two more drew no map under a knob-only config, because a
|
||||
// multi-profile element needs to be told which profile is the composite). A knob still wins.
|
||||
const cfg = JSON.stringify({ profile: t.profile, ...c.config(bakedToken) }).replace(/'/g, ''')
|
||||
// A composite that opens on a non-map view must be told to show its map (exp-shepherd: country-posters-mfe
|
||||
// reads profile-config `view`), or there is no canvas to measure.
|
||||
const VIEW = { 'country-posters-mfe': { view: 'map' } }
|
||||
const cfg = JSON.stringify({ profile: t.profile, ...(VIEW[t.element] || {}), ...c.config(bakedToken) }).replace(/'/g, ''')
|
||||
pageHtml = `<!doctype html><html><head><meta charset=utf-8><title>partner</title></head><body style="margin:0">
|
||||
<header style="height:48px;background:#eee">HOST HEADER</header>
|
||||
<script>window.MAP_APP_API_URL="https://pray.doxa.life";</script>
|
||||
|
|
|
|||
|
|
@ -130,9 +130,9 @@ const NO_LINKS = {
|
|||
|
||||
// ── D: late getCurrentInstance() sites already routed to an owner ─────────────────────────────
|
||||
const LATE_KNOWN = {
|
||||
// reflectDir runs from watch(textDir): a host switching lang to ar AFTER mount gets no dir=rtl.
|
||||
[`${PD}/dashboard-plus-grid/dashboard-plus-grid.vue#reflectDir`]: 'composite owner, routed via dx-shepherd 2026-09-26',
|
||||
[`${PD}/research-map-plus-grid/research-map-plus-grid.vue#reflectDir`]: 'composite owner, routed via dx-shepherd 2026-09-26',
|
||||
// (The two reflectDir entries that sat here are gone: landing 11 captured the instance in setup, so
|
||||
// a host switching lang to ar after mount does get dir=rtl. This gate refuses a known-issue entry
|
||||
// that no longer matches — a stale exemption is a defect it would stop reporting.)
|
||||
}
|
||||
|
||||
const fails = []
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ function hostPage(kind, s, eng, token) {
|
|||
body = snippet(s.bundle, s.profile, 'script')
|
||||
.replaceAll('src="./', `src="${BASE}/`).replaceAll('href="./', `href="${BASE}/`)
|
||||
.replaceAll('YOUR_MAPBOX_TOKEN', token).replaceAll('YOUR_SOURCE', 'shelf').replaceAll('YOUR_TREE_URL', BASE)
|
||||
if (eng === 'maplibre') body = body.replace(/<!--[^>]*remove this line[^>]*-->\s*<link[^>]*>\s*/i, '')
|
||||
if (eng === 'maplibre') body = body.replace(/<!--[^>]*(?:remove this line|this line must go)[^>]*-->\s*<link[^>]*>\s*/i, '')
|
||||
.replace(/profile-config='\{/, `profile-config='{"provider":"maplibre",`)
|
||||
}
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>PARTNER ${kind}</title>
|
||||
|
|
|
|||
|
|
@ -241,6 +241,13 @@ const r = await page.evaluate(() => {
|
|||
// presence is DETECTED rather than assumed — see the sheet arithmetic below, which must stay
|
||||
// exact about list pages whether or not this page is there.
|
||||
funnel: document.querySelectorAll('.funnel-title').length,
|
||||
lifeSheets: document.querySelectorAll('.sheet-life').length,
|
||||
christianSheets: document.querySelectorAll('.sheet-christian').length,
|
||||
lifeCells: document.querySelectorAll('.lc-grid .lc-c').length,
|
||||
prayBoxes: document.querySelectorAll('.pray-box').length,
|
||||
funnelTip: document.querySelectorAll('.funnel-tip').length,
|
||||
lifeShaded: document.querySelectorAll('.lc-grid .lc-reached').length,
|
||||
funnelEq: document.querySelectorAll('.funnel-eq').length,
|
||||
funnelSteps: document.querySelectorAll('.funnel-mine').length,
|
||||
// ★ MEASURED SO THE GATE CAN REPORT WHAT IT ASSERTED. rega: "the gate prints no funnel line, even
|
||||
// with --expect-funnel ... a reader cannot see WHAT it asserted about page 2." A silent pass is
|
||||
|
|
@ -645,12 +652,44 @@ if (!boxes.length) {
|
|||
// this assertion exists to catch SILENTLY DROPPED PEOPLE GROUPS, and a ±1 tolerance is exactly the
|
||||
// slack a lost list page would hide in. So the funnel is DETECTED (.funnel-title) and added as a
|
||||
// known term, leaving the list-page arithmetic as strict as it was.
|
||||
// ⚠ AND THE SAME FOR THE LIFE-CALENDAR PAGES (2026-09-27, Driver's page order). Two more structural
|
||||
// sheets — a 100-year life in weeks, then the SAME grid shaded by the evangelical share of these
|
||||
// groups' population — so they are DETECTED and added as known terms for exactly the reason the
|
||||
// funnel was, and for the reason stated above: a tolerance here would hide a dropped list page.
|
||||
const PER_PAGE = 20
|
||||
const funnelSheets = r.funnel ? 1 : 0
|
||||
const expectedSheets = 1 + funnelSheets + Math.ceil(r.cards / PER_PAGE) + 2
|
||||
const lifeSheets = (r.lifeSheets || 0) + (r.christianSheets || 0)
|
||||
const expectedSheets = 1 + lifeSheets + funnelSheets + Math.ceil(r.cards / PER_PAGE) + 2
|
||||
// ── THE LIFE-CALENDAR PAGES, asserted rather than merely counted ──────────────────────────────────
|
||||
// A page that EXISTS is not a page that is RIGHT: an empty grid, a grid of the wrong size, or a
|
||||
// christian page whose shading silently fell to zero would all still be one sheet. 5,200 is the
|
||||
// Driver's own arithmetic (100 years x 52 weeks) and is the only number here that may not drift.
|
||||
if (r.lifeSheets) {
|
||||
const EXPECT = 5200
|
||||
const perGrid = r.lifeCells / (r.lifeSheets + (r.christianSheets || 0))
|
||||
if (perGrid !== EXPECT) {
|
||||
fails.push(`the life calendar should be ${EXPECT} cells per grid (100 years x 52 weeks) `
|
||||
+ `but rendered ${perGrid} — the grid changed size, so the page no longer shows a human life`)
|
||||
}
|
||||
}
|
||||
// ★ THE SHADED SHARE MUST BE NON-ZERO AND NOT EVERYTHING. 0 shaded means the percentage never
|
||||
// reached the page (a null christian-share file renders the page with an empty grid, which reads as
|
||||
// "nobody is reached" — a false claim rather than missing data); 5,200 means the maths inverted.
|
||||
if (r.christianSheets) {
|
||||
const shaded = r.lifeShaded ?? null
|
||||
if (shaded !== null && (shaded <= 0 || shaded >= 5200)) {
|
||||
fails.push(`the christian-share page shaded ${shaded} of 5200 cells — 0 would claim nobody is `
|
||||
+ `reached and 5200 would claim everybody is; both mean the percentage did not arrive`)
|
||||
}
|
||||
}
|
||||
// The three prayer columns: one tick box per group per question, so the count is exactly 3 x cards.
|
||||
if (r.cards && r.prayBoxes !== r.cards * 3) {
|
||||
fails.push(`${r.cards} people cards should carry ${r.cards * 3} prayer tick boxes `
|
||||
+ `(blessed / what to know / what to do) but the packet has ${r.prayBoxes}`)
|
||||
}
|
||||
if (r.cards && r.sheets !== expectedSheets) {
|
||||
fails.push(`${r.cards} people cards should lay out as ${expectedSheets} sheets `
|
||||
+ `(1 map${funnelSheets ? ' + 1 funnel' : ''} + ${Math.ceil(r.cards / PER_PAGE)} list + points + wheel), `
|
||||
+ `(1 map${lifeSheets ? ` + ${lifeSheets} life-calendar` : ''}${funnelSheets ? ' + 1 funnel' : ''} + ${Math.ceil(r.cards / PER_PAGE)} list + points + wheel), `
|
||||
+ `but the packet has ${r.sheets} — either groups were dropped from a page or the `
|
||||
+ `${PER_PAGE}-per-page grid changed`)
|
||||
}
|
||||
|
|
@ -690,7 +729,11 @@ if (r.funnel && r.funnelRows !== r.funnelBars) {
|
|||
// description beside it ("frontier people groups") is prose and must be translated.
|
||||
// If a third entry is ever needed, say in the entry whether it is settled or deferred — an allowlist
|
||||
// that does not distinguish the two turns an open defect into a verdict.
|
||||
const LATIN_OK = new Set(['IMB', 'peoplegroups.org', 'Project'])
|
||||
// 'UPG-' is the Latin run left by "UPG-100", the PRODUCT NAME, which every locale keeps verbatim —
|
||||
// the same reason 'Project' is here (Project 4700 / 700 are list names, not prose). Verified it is the
|
||||
// only Latin left in the ar funnel page after the tip and sidebar strings were genuinely translated:
|
||||
// an allowlist entry added BEFORE translating would have hidden fifteen untranslated strings.
|
||||
const LATIN_OK = new Set(['IMB', 'peoplegroups.org', 'Project', 'UPG-'])
|
||||
const NON_LATIN_LOCALES = new Set(['hi', 'ar', 'ru', 'zh', 'he', 'fa', 'ur', 'bn', 'ta', 'th', 'ko', 'ja'])
|
||||
if (r.funnel && NON_LATIN_LOCALES.has(String(lang).split('-')[0])) {
|
||||
const stray = (r.funnelLatinRuns || []).filter((x) => !LATIN_OK.has(x))
|
||||
|
|
|
|||
|
|
@ -47,8 +47,20 @@ if (!en) { console.error('PACKET-STRINGS-PARITY: FAIL — no English block to co
|
|||
// Keys deliberately absent from translated blocks, with the reason. An entry here is a
|
||||
// DECISION on record, not a silence — and removing one tightens the gate automatically.
|
||||
const DECLARED_ABSENT = {
|
||||
point4_passage: 'Mark 5:1-20 in full — public-domain licensing per language (DECIDE-MARK5-…)',
|
||||
// ⚠ STILL DECLARED, BUT FOR FIVE FEWER LOCALES (2026-09-27). ar/ru/es/de/fr now carry a published
|
||||
// PUBLIC-DOMAIN translation each — Van Dyck, Synodal, Reina-Valera 1909, Luther 1912, Ostervald —
|
||||
// fetched with their licence metadata and recorded in data/people-groups/scripture-sources.json.
|
||||
// hi/zh/pt/it/ro remain English: PD candidates exist but I could not confirm their licence status,
|
||||
// and Scripture is never machine-translated OR reproduced from memory, which is the same failure
|
||||
// wearing more confidence. This entry shrinks as each locale's text is cited, not as a batch.
|
||||
point4_passage: 'Mark 5:1-20 — awaiting a CITED public-domain translation for hi/zh/pt/it/ro',
|
||||
point4_passage_credit: 'the attribution line for the passage above',
|
||||
// ── The Driver's new packet pages, 2026-09-27: a 100-year life calendar, the same grid shaded by
|
||||
// the evangelical share of these groups' population, and the funnel's equation sidebar + tip.
|
||||
// ENGLISH-ONLY ON PURPOSE, and declared rather than back-filled: English text copied into ten
|
||||
// locale blocks would make them read as TRANSLATED to this gate and to a reader, which is worse
|
||||
// than an honest gap — the per-KEY fallback already renders English without the lie. These come out
|
||||
// of this list one at a time as real translations arrive.
|
||||
}
|
||||
|
||||
// Language blocks are the short keys; anything longer is metadata, not a locale.
|
||||
|
|
|
|||
|
|
@ -73,6 +73,12 @@ function shadowCssOf(script, filename) {
|
|||
if (node.type === 'CallExpression' && node.callee && node.callee.name === 'useShadowStyles') {
|
||||
const acc = { css: '', complete: true }
|
||||
fold(node.arguments[0], acc)
|
||||
// The style ID (second argument). A plain identifier is the only correct shape: a CSS rule moved
|
||||
// past the first argument concatenates onto it ('@media(...){...}doxa-gospel-map'), which is valid
|
||||
// JS, keeps the build green and silently loses the rule (gospel-plugin's trap, COMMON.md).
|
||||
const lab = { text: '', literal: true, present: !!node.arguments[1] }
|
||||
if (node.arguments[1]) { const la = { css: '', complete: true }; fold(node.arguments[1], la); lab.text = la.css; lab.literal = la.complete }
|
||||
acc.label = lab
|
||||
found.push(acc)
|
||||
}
|
||||
for (const k of Object.keys(node)) {
|
||||
|
|
@ -153,6 +159,14 @@ ok(r.open === 1 && !r.orphans.length, 'a data: URL with a ; inside url() does no
|
|||
ok(got.length === 1 && got[0].complete === false,
|
||||
'an argument built partly from an imported constant is reported PARTIAL, not scanned as a whole sheet')
|
||||
}
|
||||
{
|
||||
// gospel-plugin's trap: a rule moved past the CSS argument concatenates onto the style id
|
||||
const trap = "useShadowStyles('.a{ color:red; }', '@media (max-width:1px){ .b{ color:red; } }' + 'doxa-gospel-map')"
|
||||
const lab = shadowCssOf(trap, 'x.js')[0].label
|
||||
ok(lab.literal && !/^[a-z0-9-]+$/.test(lab.text), 'the style-id arm reads a rule concatenated onto the id as NOT a plain identifier')
|
||||
const good = shadowCssOf("useShadowStyles('.a{ color:red; }', 'doxa-gospel-map')", 'x.js')[0].label
|
||||
ok(good.literal && /^[a-z0-9-]+$/.test(good.text), 'NEG CONTROL: a plain id passes the style-id arm')
|
||||
}
|
||||
|
||||
// ── the tree ────────────────────────────────────────────────────────────────────────────────────
|
||||
const walk = (d) => readdirSync(d).flatMap((f) => {
|
||||
|
|
@ -172,7 +186,13 @@ for (const f of files) {
|
|||
let blocks
|
||||
try { blocks = scriptsOf(f, src).flatMap((s) => shadowCssOf(s, f)) }
|
||||
catch (e) { console.log(` FAIL - ${rel(f)}: could not be parsed (${e.message.slice(0, 70)})`); fails++; continue }
|
||||
for (const { css, complete } of blocks) {
|
||||
for (const { css, complete, label } of blocks) {
|
||||
// ID arm: a literal style ID must be a plain identifier. A computed one (a variable) is left alone.
|
||||
if (label && label.present && label.literal && !/^[a-z0-9-]+$/.test(label.text)) {
|
||||
console.log(` FAIL - ${rel(f)}: useShadowStyles id is not a plain identifier: ${JSON.stringify(label.text.slice(0, 80))}`)
|
||||
console.log(' a CSS rule has probably been concatenated onto the id; move it back into the first argument')
|
||||
fails++
|
||||
}
|
||||
if (!/[{}]/.test(css)) { partials.push(f); continue }
|
||||
// ★ A PARTIAL ARGUMENT IS STILL SCANNED, and skipping it was a false green waiting to happen.
|
||||
// region-dashboards.vue builds its sheet as DXLIFE_TOKENS + SHADOW_BOX_SIZING_RESET + literals,
|
||||
|
|
|
|||
172
internal/check-square-map-controls.mjs
Normal file
172
internal/check-square-map-controls.mjs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
#!/usr/bin/env node
|
||||
// check-square-map-controls.mjs — `bun run check:square-map-controls`. Source-only, headless, no network.
|
||||
//
|
||||
// The square map-control variant: is it the ENGINE'S card, and does nothing else move?
|
||||
//
|
||||
// The Driver asked for square controls as an OPTION beside the round ones. The key fact that shaped
|
||||
// the implementation is that the square look is not a design of DX-life's at all: their CountryMap.vue
|
||||
// adds mapboxgl.NavigationControl + FullscreenControl and overrides NOTHING, so what he admired is the
|
||||
// ENGINE'S DEFAULT grouped card. That makes the target published and exact rather than a matter of
|
||||
// taste -- so this check reads the engine's own stylesheet out of node_modules and compares our ported
|
||||
// values against it. A port checked against its SOURCE cannot drift with my memory of it.
|
||||
//
|
||||
// AND IT ASSERTS THE BLAST RADIUS, which is the half a visual review cannot see: round is the default
|
||||
// and no consumer opts in, so all nine maps that use MapToolbar render exactly as before.
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Each copy of this repo grades ITS OWN tree (check:no-abs-paths, 2026-09-24).
|
||||
const REPO = process.env.DOXA_REPO || resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const rd = (p) => readFileSync(join(REPO, p), 'utf8')
|
||||
|
||||
let fails = 0
|
||||
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
|
||||
|
||||
const TOOLBAR_SRC = rd('library/components/map-controls/MapToolbar.vue')
|
||||
const BUTTON = rd('library/components/map-controls/MapControlButton.vue')
|
||||
|
||||
// ★ CSS ASSERTIONS READ THE STYLESHEET, NOT THE FILE. The first run of this check went red on
|
||||
// "we do not adopt 29px" -- because the prop's own doc comment QUOTES the engine's rule, 29px and all.
|
||||
// A comment is not a declaration, and a checker that cannot tell them apart will keep finding the
|
||||
// documentation it is standing on. So the CSS tests run against the text actually passed to
|
||||
// useShadowStyles; the prop and template tests still read the whole file, which is where they live.
|
||||
function shadowCss(src) {
|
||||
const at = src.indexOf('useShadowStyles(')
|
||||
const open = src.indexOf('`', at)
|
||||
const close = src.indexOf('`', open + 1)
|
||||
return open < 0 || close < 0 ? '' : src.slice(open + 1, close)
|
||||
}
|
||||
const TOOLBAR = shadowCss(TOOLBAR_SRC)
|
||||
if (!TOOLBAR.trim()) { console.log(' FAIL - could not extract MapToolbar\'s stylesheet; nothing below was measured'); process.exit(1) }
|
||||
|
||||
// ── 1. the ENGINE is the oracle, read from node_modules ─────────────────────────────────────────
|
||||
const ENGINE = 'node_modules/maplibre-gl/dist/maplibre-gl.css'
|
||||
if (existsSync(join(REPO, ENGINE))) {
|
||||
const css = rd(ENGINE).replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
const rule = (sel) => {
|
||||
const m = css.match(new RegExp(sel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\\{([^}]*)\\}'))
|
||||
return m ? m[1].replace(/\s+/g, ' ').trim() : null
|
||||
}
|
||||
const group = rule('.maplibregl-ctrl-group')
|
||||
const filled = rule('.maplibregl-ctrl-group:not(:empty)')
|
||||
const divider = rule('.maplibregl-ctrl-group button+button')
|
||||
console.log(`\n the engine's own rules, read from ${ENGINE}:`)
|
||||
console.log(` group { ${group} }`)
|
||||
console.log(` filled { ${filled} }`)
|
||||
console.log(` divider { ${divider} }`)
|
||||
|
||||
const radius = (group || '').match(/border-radius:\s*([^;]+)/)
|
||||
ok(!!radius && radius[1].trim() === '4px', `the engine's group radius is 4px, and ours is too`)
|
||||
ok(/border-radius:\s*4px;/.test(TOOLBAR), 'our .map-toolbar--square carries border-radius:4px')
|
||||
|
||||
const ring = (filled || '').match(/box-shadow:\s*([^;]+)/)
|
||||
ok(!!ring, 'the engine states a ring on a non-empty group')
|
||||
ok(/box-shadow:\s*0 0 0 2px rgba\(0, 0, 0, 0\.1\)/.test(TOOLBAR),
|
||||
`our ring matches the engine's 0 0 0 2px rgba(0,0,0,.1) (engine: ${ring && ring[1]})`)
|
||||
|
||||
ok(/border-top:\s*1px solid #ddd/.test(TOOLBAR),
|
||||
`our divider matches the engine's 1px solid #ddd (engine: ${divider})`)
|
||||
|
||||
// ★ the ONE value deliberately NOT ported
|
||||
const btn = rule('.maplibregl-ctrl-group button')
|
||||
const eng = (btn || '').match(/height:\s*(\d+)px/)
|
||||
ok(!!eng && eng[1] === '29', `the engine sizes its buttons ${eng && eng[1]}px`)
|
||||
ok(!new RegExp('height:\\s*29px').test(TOOLBAR) && !new RegExp('width:\\s*29px').test(TOOLBAR),
|
||||
'and we DO NOT adopt 29px -- the variant takes the SHAPE and leaves the SIZE, so the touch target is not cut')
|
||||
ok(/--map-btn-size/.test(TOOLBAR), 'our size still comes from --map-btn-size')
|
||||
} else {
|
||||
console.log(`\n NOT MEASURED against the engine: ${ENGINE} is absent, so the ported values were not compared to their source.`)
|
||||
fails++ // a missing oracle is not a pass
|
||||
}
|
||||
|
||||
// ── 2. the grouped card is a CONTAINER, not a radius swap ───────────────────────────────────────
|
||||
ok(/\.map-toolbar--square\s*\{[^}]*gap:\s*0/.test(TOOLBAR), 'the square toolbar sets gap:0 -- one panel, not a spaced column')
|
||||
ok(/\.map-toolbar--square\s*\{[^}]*background:\s*#fff/.test(TOOLBAR), 'the PANEL owns the background')
|
||||
ok(/\.map-toolbar--square \.mcb[^{]*\{[^}]*background:\s*transparent/.test(TOOLBAR), 'and the buttons own none of it')
|
||||
ok(/\.map-toolbar--square \.mcb[^{]*\{[^}]*box-shadow:\s*none/.test(TOOLBAR), 'the buttons drop their own shadow, so there is one ring not N')
|
||||
ok(/\.map-toolbar--square > \* \+ \*\s*\{[^}]*border-top/.test(TOOLBAR),
|
||||
'the divider is on the toolbar CHILDREN, so it still lands where two different control types meet')
|
||||
ok(/\.map-toolbar--square \.hbg-ctrl\s*\{[^}]*margin:\s*0/.test(TOOLBAR),
|
||||
"the hamburger wrapper's 10px margin is zeroed -- it would otherwise tear the panel apart")
|
||||
|
||||
// ★ no overflow:hidden, and the reason is a focus ring
|
||||
ok(!/\.map-toolbar--square\s*\{[^}]*overflow:\s*hidden/.test(TOOLBAR),
|
||||
'the square panel does NOT use overflow:hidden -- it would clip a focus ring on any inner button')
|
||||
ok(/:first-child[^{]*\{[^}]*border-radius:\s*4px 4px 0 0/.test(TOOLBAR) &&
|
||||
/:last-child[^{]*\{[^}]*border-radius:\s*0 0 4px 4px/.test(TOOLBAR),
|
||||
'outer corners come from first/last child rounding instead, which is the engine\'s own mechanism')
|
||||
ok(/:only-child[^{]*\{[^}]*border-radius:\s*4px/.test(TOOLBAR), 'and a lone control is rounded on all four corners')
|
||||
|
||||
// ── 2b. THE PANEL SURVIVES THE PHONE, which it did not at first ────────────────────────
|
||||
// A media query adds NO specificity, so the mobile `.map-toolbar { gap: 6px }` is (0,1,0) exactly like
|
||||
// `.map-toolbar--square { gap: 0 }` and, coming later, WON -- a square toolbar fell back to six
|
||||
// separate buttons inside one ringed panel on every phone. The --no-geocoder pair is (0,2,0) and
|
||||
// outranked the variant whatever the order, so it needs the two-class answer.
|
||||
{
|
||||
const mq = TOOLBAR.indexOf('@media (max-width: 767px)')
|
||||
ok(mq > -1, 'the mobile media block is present')
|
||||
const mobile = mq > -1 ? TOOLBAR.slice(mq) : ''
|
||||
ok(/\.map-toolbar--square[^{]*\{[^}]*gap:\s*0/.test(mobile),
|
||||
'the square variant re-states gap:0 INSIDE the mobile block, so the phone rule cannot outrank it')
|
||||
ok(/\.map-toolbar--square\.map-toolbar--no-geocoder[^{]*\{[^}]*gap:\s*0/.test(mobile),
|
||||
'and answers the (0,2,0) --no-geocoder pair with the two-class form')
|
||||
const sqGap = TOOLBAR.indexOf('.map-toolbar--square {')
|
||||
ok(sqGap > -1 && sqGap < mq, 'CONTROL: the base square rule really does come BEFORE the media block, which is why the override is needed at all')
|
||||
}
|
||||
|
||||
// ── 3. forced colours, which the round buttons never had ────────────────────────────────────────
|
||||
ok(/@media \(forced-colors: active\)/.test(TOOLBAR), 'the square variant ships a forced-colors block')
|
||||
ok(/ButtonText/.test(TOOLBAR), 'using ButtonText, so the ring and dividers survive a replaced palette')
|
||||
|
||||
// ── 4. the standalone per-button override ───────────────────────────────────────────────────────
|
||||
ok(/shape:\s*\{ type: String,\s*default: 'round' \}/.test(BUTTON), "MapControlButton's shape defaults to 'round'")
|
||||
ok(/\.mcb--square\{ border-radius:4px; \}/.test(BUTTON), 'a standalone square takes the engine\'s 4px corners')
|
||||
ok(/'mcb--square': shape === 'square'/.test(BUTTON), 'and the class is bound to the prop')
|
||||
|
||||
// ── 5. BLAST RADIUS: round is the default and nobody opted in ────────────────────────────────────
|
||||
ok(/controls:\s*\{ type: String, default: 'round' \}/.test(TOOLBAR_SRC), "MapToolbar's controls prop defaults to 'round'")
|
||||
const CONSUMERS = [
|
||||
['doxa-simple-map', 'plugin-designs/doxa-simple-map/doxa-simple-map.vue', 8],
|
||||
['resources-map', 'plugin-designs/gospel-resources/resources-map.vue', 5],
|
||||
['upg-100-list', 'plugin-designs/my-upg-100-list/upg-100-list.vue', 6],
|
||||
['countries-map', 'plugin-designs/country-posters/countries-map.vue', 5],
|
||||
['DashboardMapPanel', 'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue', 2],
|
||||
['demo-map', 'plugin-designs/demo-map/demo-map.vue', 5],
|
||||
['connectivity-map', 'plugin-designs/cities-internet/connectivity-map.vue', 5],
|
||||
['research-map', 'plugin-designs/doxa-research-map/research-map.vue', 7],
|
||||
['all-people-groups', 'plugin-designs/all-people-groups/all-people-groups.vue', 1],
|
||||
]
|
||||
let optedIn = 0
|
||||
for (const [name, path] of CONSUMERS) {
|
||||
const src = rd(path)
|
||||
const m = src.match(/<MapToolbar[^>]*controls=/)
|
||||
if (m) { optedIn++; console.log(` ${name} opts in`) }
|
||||
}
|
||||
ok(optedIn === 0, `none of the ${CONSUMERS.length} MapToolbar consumers passes controls=, so every one renders exactly as before (${optedIn} opted in)`)
|
||||
|
||||
// ── 6. THE GEOMETRY, for rega to confirm with eyes ──────────────────────────────────────────────
|
||||
// A square toolbar is SHORTER, because the gaps become dividers. That is the one measurable knock-on:
|
||||
// MapControlMenu's matchToolbar limits a menu to the toolbar column's height.
|
||||
const BTN = 32, GAP_DESKTOP = 8, GAP_MOBILE = 6
|
||||
console.log('\n toolbar column height, round vs square (button 32px; ring is box-shadow, so outside layout):')
|
||||
for (const [name, , n] of CONSUMERS) {
|
||||
const round = n * BTN + (n - 1) * GAP_DESKTOP
|
||||
const square = n * BTN + (n - 1) * 1 // 1px dividers replace the 8px gaps
|
||||
console.log(` ${String(n).padStart(2)} control(s) ${name.padEnd(18)} round ${String(round).padStart(3)}px square ${String(square).padStart(3)}px shorter by ${round - square}px`)
|
||||
}
|
||||
const five = { round: 5 * BTN + 4 * GAP_DESKTOP, square: 5 * BTN + 4 }
|
||||
ok(five.square < five.round,
|
||||
`a 5-control toolbar goes ${five.round}px -> ${five.square}px, so a matchToolbar menu gets ${five.round - five.square}px shorter -- rega should confirm the menu still fits its content`)
|
||||
console.log(` at mobile gap ${GAP_MOBILE}px a 5-control toolbar is ${5 * BTN + 4 * GAP_MOBILE}px round vs ${five.square}px square`)
|
||||
|
||||
// ── 7. NEG CONTROLS ─────────────────────────────────────────────────────────────────────────────
|
||||
ok(!/\.map-toolbar\s*\{[^}]*border-radius/.test(TOOLBAR),
|
||||
'NEG CONTROL: the DEFAULT .map-toolbar has no border-radius, so the square rules are genuinely scoped to the variant')
|
||||
ok(/border-radius:\s*50%/.test(BUTTON),
|
||||
'NEG CONTROL: the round button is still a 50% circle -- the default was not quietly changed')
|
||||
ok(!/controls === 'round'/.test(TOOLBAR_SRC),
|
||||
'NEG CONTROL: nothing branches on round, so round is the ABSENCE of the square class rather than a second code path')
|
||||
|
||||
console.log(fails ? `\nSQUARE-CONTROLS: ${fails} FAIL(s)` : '\nSQUARE-CONTROLS: the variant matches the engine\'s own rules, keeps our touch target, and changes no consumer')
|
||||
process.exit(fails ? 1 : 0)
|
||||
|
|
@ -5,7 +5,9 @@
|
|||
* The Driver's ask (2026-09-26): one FOLDER per BUNDLE with its ELEMENTS inside, bundles sorted by
|
||||
* capability, highest first. Runs the studio's REAL treeSections / compositionTrees / rowsFromManifest
|
||||
* (loaded through Vite, no browser) against the BUILT manifest and asserts:
|
||||
* - sections run CAP 5 → 1, and each bundle sits in the section of its HIGHEST capability, once;
|
||||
* - PRODUCTION bundles (manifest bundle.stage, from their .meta.json) sit in capability sections that run
|
||||
* CAP 5 → 1, each in the section of its HIGHEST capability, once; every EXPERIMENTAL bundle sits in one
|
||||
* final Experimental section, highest capability first (the Driver, 2026-09-27);
|
||||
* - bundles within a section are in name order;
|
||||
* - a bundle with several elements is ONE folder holding all of them; a single-element bundle is its element;
|
||||
* - every studio row has exactly ONE canonical node (one identity per plugin; parts only target it).
|
||||
|
|
@ -36,7 +38,11 @@ try {
|
|||
ctx.resolveRef = (ref, fb) => comp.methods.resolveRef.call(ctx, ref, fb)
|
||||
ctx.compositionTrees = comp.computed.compositionTrees.call(ctx)
|
||||
const secs = comp.computed.treeSections.call(ctx)
|
||||
const caps = secs.map((s) => s.cap)
|
||||
const stageOf = new Map(manifest.bundles.map((b) => [b.bundle, b.stage === 'production' ? 'production' : 'experimental']))
|
||||
const expIdx = secs.findIndex((s) => s.id === 'stage-experimental')
|
||||
if (expIdx !== -1 && expIdx !== secs.length - 1) fail('the Experimental section is not last')
|
||||
const capSecs = secs.filter((s) => s.id !== 'stage-experimental')
|
||||
const caps = capSecs.map((s) => s.cap)
|
||||
if (caps.join() !== [...caps].sort((a, b) => b - a).join()) fail(`sections not CAP 5 → 1: ${caps.join(',')}`)
|
||||
const byBundle = new Map()
|
||||
for (const r of ctx.rows) { if (!byBundle.has(r.bundle)) byBundle.set(r.bundle, []); byBundle.get(r.bundle).push(r) }
|
||||
|
|
@ -44,13 +50,21 @@ try {
|
|||
for (const s of secs) {
|
||||
const tops = s.nodes.filter((n) => n.depth === 0)
|
||||
const names = tops.map((n) => (n.folder ? n.label : ctx.rows.find((r) => r.key === n.target)?.bundle))
|
||||
if (names.join() !== [...names].sort((a, b) => a.localeCompare(b)).join()) fail(`CAP ${s.cap}: bundles not in name order (${names.join(', ')})`)
|
||||
const isExp = s.id === 'stage-experimental'
|
||||
const topCap = (b) => Math.max(...(byBundle.get(b) || []).map((r) => r.cap))
|
||||
const want = isExp ? [...names].sort((a, b) => topCap(b) - topCap(a) || a.localeCompare(b)) : [...names].sort((a, b) => a.localeCompare(b))
|
||||
if (names.join() !== want.join()) fail(`${isExp ? 'Experimental' : 'CAP ' + s.cap}: bundles not in order (${names.join(', ')})`)
|
||||
for (const b of names) {
|
||||
const st = stageOf.get(b)
|
||||
if (isExp && st === 'production') fail(`production bundle ${b} is filed under Experimental`)
|
||||
if (!isExp && st !== 'production') fail(`experimental bundle ${b} is filed under CAP ${s.cap}, not Experimental`)
|
||||
}
|
||||
for (const [i, n] of tops.entries()) {
|
||||
const b = names[i]; const rows = byBundle.get(b) || []
|
||||
if (seen.has(b)) fail(`bundle ${b} appears twice (CAP ${seen.get(b)} and CAP ${s.cap})`)
|
||||
seen.set(b, s.cap)
|
||||
const top = Math.max(...rows.map((r) => r.cap))
|
||||
if (top !== s.cap) fail(`bundle ${b} (highest CAP ${top}) filed under CAP ${s.cap}`)
|
||||
if (!isExp && top !== s.cap) fail(`bundle ${b} (highest CAP ${top}) filed under CAP ${s.cap}`)
|
||||
if (rows.length > 1) {
|
||||
if (!n.folder) fail(`bundle ${b} has ${rows.length} elements but is not a folder`)
|
||||
const kids = s.nodes.filter((k) => k.parent === n.id && k.canonical).map((k) => k.target).sort()
|
||||
|
|
@ -61,7 +75,8 @@ try {
|
|||
for (const b of byBundle.keys()) if (!seen.has(b)) fail(`bundle ${b} is missing from the rail`)
|
||||
const canon = secs.flatMap((s) => s.nodes.filter((n) => n.canonical))
|
||||
for (const r of ctx.rows) { const c = canon.filter((n) => n.target === r.key).length; if (c !== 1) fail(`${r.key} has ${c} canonical nodes (want 1)`) }
|
||||
console.log(`STUDIO-RAIL: ${byBundle.size} bundle(s), ${ctx.rows.length} element(s), ${secs.length} capability section(s); folders: ${secs.flatMap((s) => s.nodes.filter((n) => n.folder).map((n) => n.label + '(' + n.kids + ')')).join(', ')}`)
|
||||
const nProd = [...stageOf.values()].filter((v) => v === 'production').length
|
||||
console.log(`STUDIO-RAIL: ${byBundle.size} bundle(s) (${nProd} production, ${byBundle.size - nProd} experimental), ${ctx.rows.length} element(s), ${secs.length} section(s); folders: ${secs.flatMap((s) => s.nodes.filter((n) => n.folder).map((n) => n.label + '(' + n.kids + ')')).join(', ')}`)
|
||||
} finally { await server.close() }
|
||||
if (bad) { console.log(`STUDIO-RAIL: FAIL — ${bad} problem(s).`); process.exit(1) }
|
||||
console.log('STUDIO-RAIL: PASS — one folder per bundle with its elements inside, bundles grouped by their highest capability (5 → 1) and in name order, one identity per plugin.')
|
||||
console.log('STUDIO-RAIL: PASS — one folder per bundle with its elements inside; production bundles grouped by their highest capability (5 → 1) in name order, experimental bundles in one final Experimental section; one identity per plugin.')
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ const BUNDLES = [
|
|||
]
|
||||
const ALLOW = {
|
||||
'prayer-walk-planner:ENG-PIN': 'keyless by design: always MapLibre, needs no token (index.js header)',
|
||||
'key-cities:ENG-STYLE': 'gated, Mapbox-only by design (mapbox:// styles, ensureEngineScript(\'mapbox\'))',
|
||||
'all-people-groups:LIST-DSM': 'draws the IMB dataset (all 12,379 groups), not the DOXA list: a list there is Tier B (SPEC-PEOPLE-GROUP-LIST-PROP.md)',
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +83,40 @@ const RULES = [
|
|||
{ id: 'ENG-PIN', prop: 'engine', re: /useMapInstance\s*\(\s*\{[^}]{0,400}?provider\s*:\s*['"](?:mapbox|maplibre)['"]/g },
|
||||
{ id: 'TOK-GLOBAL', prop: 'token', re: /window\.DOXA_TOKEN\b/g, shipped: 'DOXA_TOKEN' },
|
||||
{ id: 'TOK-LITERAL', prop: 'token', re: /['"`]pk\.ey[A-Za-z0-9_-]{20,}/g, shipped: /pk\.eyJ[A-Za-z0-9_-]{20,}/ },
|
||||
{ id: 'LOC-ENUS', prop: 'locale', warn: true, re: /(?:toLocaleString|NumberFormat|DateTimeFormat)\s*\(\s*['"]en(?:-US)?['"]/g },
|
||||
{ id: 'LOC-ENUS', prop: 'locale', warn: true, test(src) {
|
||||
// ★ A CATCH-BLOCK FALLBACK IS NOT A FIXED FORMAT, and the bare regex was WARNING THE CORRECT
|
||||
// PATTERN. regions formats with `${locale}-u-nu-latn` -- a verbatim clone of DX-life's
|
||||
// RegionDetail.vue:16, Latin digits with the reader's grouping -- and falls back to 'en-US' only
|
||||
// inside a catch, because a bad locale tag throws RangeError in toLocaleString and blanking the
|
||||
// page is worse than one wrong separator. Flagging that told the matrix's locale column to say
|
||||
// "ignores the locale" about code that honours it, which is the kind of false warn that teaches
|
||||
// people to stop reading the column.
|
||||
// MEASURED before changing the rule: of the NINE LOC-ENUS sites in the tree, exactly TWO are
|
||||
// such fallbacks (region-dashboards.vue, regions-list.vue). The other seven are genuinely
|
||||
// operative fixed en-US and are still flagged, including RegionCountryMap's popup, which is a
|
||||
// HELD Driver decision with its own card rather than an oversight.
|
||||
const out = []
|
||||
const re = /(?:toLocaleString|NumberFormat|DateTimeFormat)\s*\(\s*['"]en(?:-US)?['"]/g
|
||||
let m
|
||||
while ((m = re.exec(src))) {
|
||||
const before = src.slice(src.lastIndexOf('\n', m.index) + 1, m.index)
|
||||
// both halves required: a catch, AND a locale-DERIVED format call it is the fallback for.
|
||||
// A lone `catch` nearby must not excuse a hardcoded format that never consulted the locale.
|
||||
const isFallback = /\bcatch\b/.test(before)
|
||||
&& /(?:toLocaleString|NumberFormat|DateTimeFormat)\s*\(\s*`[^`]*\$\{/.test(before)
|
||||
if (!isFallback) out.push(m.index)
|
||||
}
|
||||
// ★ THE NAMED CONSTANT COUNTS TOO, or folding the literal into one module LOOKS like fixing it.
|
||||
// library/utils/formatCount.js now holds the fleet's en-US decision and calls
|
||||
// toLocaleString(COUNT_LOCALE) — no literal — so the pattern above went quiet and 8 of the
|
||||
// tree's 13 warns vanished with the BEHAVIOUR unchanged. That is the same false-green as a
|
||||
// precondition that skips the file under test: the audit stopped seeing a decision still in
|
||||
// force. One site, attributed to every bundle that imports it, is what "held in one place"
|
||||
// should read as in the matrix — not zero.
|
||||
const constRe = /COUNT_LOCALE\s*=\s*['"]en(?:-US)?['"]/g
|
||||
while ((m = constRe.exec(src))) out.push(m.index)
|
||||
return out
|
||||
} },
|
||||
// A whole-file rule: the fetch and the helper need not be on the same line, only in the same file.
|
||||
{ id: 'LIST-FETCH', prop: 'list', test(src) {
|
||||
const m = /\/api\/people-groups\/list|endpoints\?\.bulk|doxa-people-groups\.json/.exec(src)
|
||||
|
|
@ -112,10 +144,29 @@ const CLEAN = `map.setStyle(resolveNamedStyle('ink', p).style || 'mapbox://style
|
|||
useMapInstance({ container: el, style: cfg.style ?? 'mapbox://styles/mapbox/light-v11' })
|
||||
// new mapboxgl.Popup() in a comment is not code
|
||||
n.toLocaleString(locale.value)
|
||||
try { return v.toLocaleString(\`\${locale.value || 'en'}-u-nu-latn\`) } catch (_) { return v.toLocaleString('en-US') }
|
||||
const r2 = await applyPeopleGroupList((await fetch(base + '/api/people-groups/list')).posts, cfg)
|
||||
const dsm2 = new DataSourceManager({ locale: 'en', profileConfig })
|
||||
const INHERITED_PAGE_KEYS = ['locale', 'provider', 'list']`
|
||||
const falsePos = RULES.filter((r) => run(r, stripComments(CLEAN)).length).map((r) => r.id)
|
||||
|
||||
// ── LOC-ENUS discrimination, tested on purpose ──────────────────────────────────────────────────
|
||||
// The fallback exemption is the one rule here that can WIDEN by accident: loosen it and a hardcoded
|
||||
// format with a catch anywhere near it stops being seen. So the three adversarial shapes are pinned.
|
||||
const locRule = RULES.find((r) => r.id === 'LOC-ENUS')
|
||||
const LOC_CASES = [
|
||||
[false, "try { return v.toLocaleString(`${locale.value || 'en'}-u-nu-latn`) } catch (_) { return v.toLocaleString('en-US') }"],
|
||||
[true, "try { return v.toLocaleString('en-US') } catch (_) { return '' }"],
|
||||
[true, "try { return fmt(v) } catch (_) { return v.toLocaleString('en-US') }"],
|
||||
[true, "const pop = Number(p.population).toLocaleString('en-US')"],
|
||||
]
|
||||
const locWrong = LOC_CASES.filter(([shouldFlag, line]) => (run(locRule, line).length > 0) !== shouldFlag)
|
||||
if (locWrong.length) {
|
||||
console.error(`UNIVERSAL-PROPS: SELF-TEST FAILED — LOC-ENUS misjudged ${locWrong.length} case(s); `
|
||||
+ 'the catch-fallback exemption must skip ONLY a locale-derived primary: '
|
||||
+ locWrong.map(([f, l]) => `${f ? 'missed' : 'false-flagged'}: ${l.slice(0, 56)}`).join(' | '))
|
||||
process.exit(3)
|
||||
}
|
||||
if (missed.length || falsePos.length) {
|
||||
console.error(`UNIVERSAL-PROPS: SELF-TEST FAILED — ${missed.length ? 'rules blind to a planted bypass: ' + missed.join(', ') : ''}${falsePos.length ? ' false positives on clean code: ' + falsePos.join(', ') : ''}`)
|
||||
process.exit(3)
|
||||
|
|
@ -133,15 +184,59 @@ function files(dir) {
|
|||
}
|
||||
return out
|
||||
}
|
||||
/* ★ A BUNDLE'S SOURCE IS NOT ONLY ITS OWN FOLDER ANY MORE. This gate scanned
|
||||
* plugin-designs/<bundle>/** and nothing else, which was right while every panel lived in its
|
||||
* plugin. The dashboard-kit extraction moves panels into library/components/dashboards/, and a
|
||||
* bundle still COMPILES them — so a rule that fires on the source stopped firing the moment the
|
||||
* file moved, with the code unchanged. MEASURED: kit landing 4c-3 moved DashboardFacetRail.vue,
|
||||
* which holds 3 toLocaleString('en-US') calls, and this matrix went from 13 warns to 10. Nothing
|
||||
* improved; the audit just stopped looking. LOC-ENUS has no `shipped` matcher, so the built-bundle
|
||||
* pass below could not catch them either.
|
||||
* So each bundle is now audited over the library files it actually IMPORTS, resolved from its own
|
||||
* `@map/...` specifiers and followed transitively. Attribution stays per-bundle, which is what
|
||||
* makes the matrix actionable: a library file consumed by four bundles is reported against all
|
||||
* four, because all four ship it. */
|
||||
function libraryImportsFrom(startFiles) {
|
||||
const seen = new Set()
|
||||
const queue = [...startFiles]
|
||||
while (queue.length) {
|
||||
const f = queue.shift()
|
||||
let src = ''
|
||||
try { src = readFileSync(f, 'utf8') } catch (_) { continue }
|
||||
for (const m of stripComments(src).matchAll(/from\s*['"]@map\/([^'"]+)['"]/g)) {
|
||||
const rel = join('library', m[1])
|
||||
const abs = resolve(ROOT, rel)
|
||||
if (seen.has(abs) || !existsSync(abs)) continue
|
||||
// Same extension filter the per-bundle walk uses. Without it the sweep picked up
|
||||
// library/api/sources.json — the endpoint REGISTRY — and LIST-FETCH reported it as a file that
|
||||
// fetches the list, three times over. A .json file declares endpoints; it fetches nothing.
|
||||
if (!/\.(vue|js|mjs)$/.test(abs)) continue
|
||||
let st
|
||||
try { st = statSync(abs) } catch (_) { continue }
|
||||
if (!st.isFile()) continue
|
||||
seen.add(abs)
|
||||
queue.push(abs)
|
||||
}
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
const filesWithLibrary = (dir) => { const own = files(dir); return [...own, ...libraryImportsFrom(own)] }
|
||||
|
||||
const findings = []
|
||||
const matrix = {}
|
||||
for (const b of BUNDLES.filter((x) => !ONLY || ONLY.includes(x))) {
|
||||
const dir = resolve(ROOT, 'plugin-designs', b)
|
||||
if (!existsSync(dir)) continue
|
||||
matrix[b] = { engine: 'ok', token: 'ok', locale: 'ok', list: 'ok' }
|
||||
for (const f of files(dir)) {
|
||||
for (const f of filesWithLibrary(dir)) {
|
||||
const src = stripComments(readFileSync(f, 'utf8'))
|
||||
for (const r of RULES) for (const idx of run(r, src)) {
|
||||
// A module is not a bypass of its own contract. library/api/DataSourceManager.js ends with
|
||||
// `const dataSourceManager = new DataSourceManager()` — the singleton it exports — and LIST-DSM
|
||||
// exists to catch a CALLER constructing one without profileConfig. Reported against three
|
||||
// bundles when the sweep first reached it, which is a gate telling four owners that the library
|
||||
// is broken because it read the definition as a use.
|
||||
if (r.id === 'LIST-DSM' && /library\/api\/DataSourceManager\.js$/.test(f.replace(/\\/g, '/'))) continue
|
||||
const allowed = ALLOW[`${b}:${r.id}`]
|
||||
if (allowed) { if (matrix[b][r.prop] === 'ok') matrix[b][r.prop] = 'allowed'; continue }
|
||||
findings.push({ b, rule: r.id, prop: r.prop, warn: !!r.warn, where: `${relative(ROOT, f)}:${lineOf(src, idx)}`, text: src.slice(idx, idx + 70).replace(/\s+/g, ' ').trim() })
|
||||
|
|
|
|||
262
internal/check-zoning-declared.mjs
Normal file
262
internal/check-zoning-declared.mjs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-zoning-declared — a project list's ZONING is consistent with what its author DECLARED.
|
||||
*
|
||||
* WHY THIS EXISTS, and why it is a declaration-checker rather than a rule-checker. UPG-100 is becoming a
|
||||
* PATTERN: any project list (7000 / 4700 / 3000 / 2000 / 700) gets its own ~100-group clusters, zoned by
|
||||
* an agent who is not the author of this code. Zoning is human judgement — geography first, balanced with
|
||||
* language — so a gate cannot decide whether a zoning is GOOD. What it can decide is whether the zoning
|
||||
* matches what the author said it would be, which turns "did I zone this right?" from a browser question
|
||||
* into an exit code.
|
||||
*
|
||||
* ★ EVERY OBVIOUS RULE I WOULD HAVE WRITTEN FROM INTUITION FAILED THE REFERENCE IMPLEMENTATION, measured
|
||||
* against the live 28 zones and 2,107 records on 2026-09-27. That is the reason this file is shaped the
|
||||
* way it is:
|
||||
*
|
||||
* "every group in exactly one zone" → 112 groups are in two. 110 are the DEAF BLOC, and that is
|
||||
* design-approved (legend-tree.js:151, slice 08): an affinity
|
||||
* bloc's members also live in a geographic zone. Per-zone
|
||||
* equality holds; only a naive GLOBAL sum over-counts. So the
|
||||
* rule is "at most one GEOGRAPHIC zone", with blocs declared.
|
||||
* "no group is unzoned" → 11 are, all Japan/China/Mongolia, and plausibly deliberate.
|
||||
* A gate cannot tell a deliberate exclusion from a silent drop.
|
||||
* Hence expected_orphans: the author states them, and the gate
|
||||
* fails on a CHANGE rather than on their existence.
|
||||
* "no two zones overlap" → 2 non-bloc overlaps exist (cocos-malays, andorrans) because
|
||||
* "Scattered / Diaspora" overlaps the regions it scatters
|
||||
* across. Declared, pending the Driver.
|
||||
*
|
||||
* A gate that fails on the only correct example of the thing it checks teaches people to disable it.
|
||||
*
|
||||
* ⚠ AND TWO OF THESE ASSERTIONS WOULD HAVE CAUGHT THIS CYCLE'S REAL DEFECTS BEFORE A BROWSER DID.
|
||||
* A1 (every zone resolves to ≥1 group) is exactly the state `deaf` and the five wagf zones were in when
|
||||
* the packet generator skipped them with "no groups resolved" — six of 28 zones silently producing
|
||||
* nothing, found only by generating all 28 and reading the log. A4 (every selector type is supported)
|
||||
* is the other: selector-evaluator THROWS on an unknown type, which surfaces at render time on one
|
||||
* zone's page rather than at build time on the whole set.
|
||||
*
|
||||
* Usage: node internal/check-zoning-declared.mjs [--list upg-100] [--records <file>]
|
||||
* node internal/check-zoning-declared.mjs --self-test
|
||||
*
|
||||
* Exit 0 pass · 1 fail · 75 NOT MEASURED (no records source — "found nothing" is not "found nothing wrong")
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = process.env.DOXA_REPO || resolve(HERE, '..')
|
||||
const TAG = 'ZONING-DECLARED'
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const SELF_TEST = argv.includes('--self-test')
|
||||
const flag = (name, dflt) => {
|
||||
const i = argv.indexOf(`--${name}`)
|
||||
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : dflt
|
||||
}
|
||||
const LIST = flag('list', 'upg-100')
|
||||
|
||||
// ── the five assertions, as pure functions so --self-test can exercise them without any data ──────────
|
||||
|
||||
/** A1 — a zone that resolves to nothing is a broken selector, not an empty region. */
|
||||
export function findEmptyZones(zoneSlugs) {
|
||||
return [...zoneSlugs].filter(([, slugs]) => !slugs || slugs.length === 0).map(([id]) => id)
|
||||
}
|
||||
|
||||
/** A2 — at most one GEOGRAPHIC zone per group. Bloc zones are a declared second partition. */
|
||||
export function findGeographicOverlaps(zoneSlugs, blocIds) {
|
||||
const bloc = new Set(blocIds || [])
|
||||
const owners = new Map()
|
||||
for (const [id, slugs] of zoneSlugs) {
|
||||
if (bloc.has(id)) continue
|
||||
for (const s of slugs) {
|
||||
if (!owners.has(s)) owners.set(s, [])
|
||||
owners.get(s).push(id)
|
||||
}
|
||||
}
|
||||
return [...owners].filter(([, zs]) => zs.length > 1).map(([slug, zones]) => ({ slug, zones }))
|
||||
}
|
||||
|
||||
/** A3 — the orphan set must EQUAL the declared one. Both directions matter (see below). */
|
||||
export function diffOrphans(actual, declared) {
|
||||
const a = new Set(actual); const d = new Set(declared || [])
|
||||
return {
|
||||
// A new orphan is a group that silently stopped being zoned — the failure this gate exists for.
|
||||
unexpected: [...a].filter((s) => !d.has(s)).sort(),
|
||||
// ★ AND A *RESOLVED* ORPHAN FAILS TOO, which is deliberate and non-obvious. If a declared orphan is
|
||||
// now zoned, that is good news and a STALE DECLARATION: leaving it listed means the next real drop of
|
||||
// that same group passes silently. A declaration that is not maintained stops being evidence.
|
||||
stale: [...d].filter((s) => !a.has(s)).sort(),
|
||||
}
|
||||
}
|
||||
|
||||
/** A4 — every selector type must be one the evaluator supports (it throws at render time otherwise). */
|
||||
export function findUnsupportedSelectors(selectorsById, supported) {
|
||||
const ok = new Set(supported)
|
||||
const bad = []
|
||||
const walk = (id, sel) => {
|
||||
if (!sel || !sel.type) { bad.push({ id, type: '(none)' }); return }
|
||||
if (!ok.has(sel.type)) bad.push({ id, type: sel.type })
|
||||
if (sel.type === 'union') for (const s of sel.selectors || []) walk(id, s)
|
||||
}
|
||||
for (const [id, sel] of selectorsById) walk(id, sel)
|
||||
return bad
|
||||
}
|
||||
|
||||
/** A5 — a zone wildly over the page budget is usually a selector that matched too much. */
|
||||
export function findOversizedZones(zoneSlugs, maxGroups) {
|
||||
if (!(maxGroups > 0)) return []
|
||||
return [...zoneSlugs]
|
||||
.filter(([, slugs]) => (slugs?.length || 0) > maxGroups)
|
||||
.map(([id, slugs]) => ({ id, count: slugs.length }))
|
||||
}
|
||||
|
||||
// ── self-test ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
if (SELF_TEST) {
|
||||
// ★ PROVE EACH ASSERTION FIRES AND THAT IT IGNORES THE DECLARED CASES. A gate whose assertions have
|
||||
// never matched anything is indistinguishable from a gate with an inverted condition, and three of
|
||||
// these five are subtle enough that "it passed on the real data" is not evidence they work.
|
||||
const fails = []
|
||||
const ok = (cond, msg) => { console.log(` ${cond ? 'ok ' : 'FAIL'} ${msg}`); if (!cond) fails.push(msg) }
|
||||
|
||||
ok(findEmptyZones([['a', ['x']], ['b', []]]).join() === 'b', 'A1 flags an empty zone, not a full one')
|
||||
ok(findEmptyZones([['a', ['x']]]).length === 0, 'A1 passes when every zone resolves')
|
||||
|
||||
const zs = [['geo1', ['p', 'q']], ['geo2', ['q']], ['deaf', ['p', 'q']]]
|
||||
ok(findGeographicOverlaps(zs, ['deaf']).map((o) => o.slug).join() === 'q',
|
||||
'A2 flags the geographic overlap and EXEMPTS the declared bloc (this is the deaf double-count)')
|
||||
ok(findGeographicOverlaps(zs, []).length === 2,
|
||||
'A2 WITHOUT the bloc declaration flags both — so the exemption is doing the work, not the regex')
|
||||
|
||||
const d1 = diffOrphans(['new', 'known'], ['known'])
|
||||
ok(d1.unexpected.join() === 'new' && d1.stale.length === 0, 'A3 flags a NEW orphan')
|
||||
const d2 = diffOrphans([], ['known'])
|
||||
ok(d2.stale.join() === 'known' && d2.unexpected.length === 0,
|
||||
'A3 also flags a RESOLVED orphan as a stale declaration — an unmaintained declaration is not evidence')
|
||||
ok(diffOrphans(['known'], ['known']).unexpected.length === 0, 'A3 passes on an exact match')
|
||||
|
||||
const sup = ['country_code', 'union']
|
||||
ok(findUnsupportedSelectors([['z', { type: 'wagf_block' }]], sup)[0].type === 'wagf_block',
|
||||
'A4 flags an unsupported type')
|
||||
ok(findUnsupportedSelectors([['z', { type: 'union', selectors: [{ type: 'nope' }] }]], sup)
|
||||
.some((b) => b.type === 'nope'), 'A4 descends INTO a union — southern-africa-islands is one')
|
||||
ok(findUnsupportedSelectors([['z', null]], sup)[0].type === '(none)', 'A4 flags a missing selector')
|
||||
|
||||
ok(findOversizedZones([['big', new Array(300).fill('x')]], 200)[0].count === 300, 'A5 flags an oversized zone')
|
||||
ok(findOversizedZones([['ok', new Array(120).fill('x')]], 200).length === 0, 'A5 passes a large-but-sane zone')
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\n${TAG} --self-test: FAIL — ${fails.length} case(s) wrong.`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\n${TAG} --self-test: PASS — all five assertions fire on a real violation and stay quiet on `
|
||||
+ 'the declared exceptions.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// ── the run ───────────────────────────────────────────────────────────────────────────────────────────
|
||||
const DATA = join(REPO, 'plugin-designs', 'my-upg-100-list', 'data')
|
||||
const declPath = join(DATA, LIST === 'upg-100' ? 'zoning-declarations.json' : `zoning-declarations.${LIST}.json`)
|
||||
if (!existsSync(declPath)) {
|
||||
console.log(`${TAG}: NOT MEASURED — no declarations at ${declPath}.`)
|
||||
console.log(' A list without declarations cannot be checked against them. Copy upg-100\'s and fill it in.')
|
||||
process.exit(75)
|
||||
}
|
||||
const decl = JSON.parse(readFileSync(declPath, 'utf8'))
|
||||
|
||||
// Records: the same source everything else uses. NOT fetched here — a gate that needs the network is a
|
||||
// gate that SKIPs in CI, which is how check:packets-all went unevaluated on every build for weeks.
|
||||
// ★ THE COMMITTED FIXTURE IS THE DEFAULT, the live cache only an override.
|
||||
// ⚠ IT USED TO DEFAULT TO library/scripts/posters/out/_records.json, WHICH IS GITIGNORED
|
||||
// (.gitignore:94) — so on a clean checkout this gate exited 75 and reported NOT MEASURED forever,
|
||||
// which is indistinguishable from an unwired gate. studio measured exactly that before wiring it into
|
||||
// gates:post (L13U) and correctly refused to take it. A gate that cannot run in CI protects nothing;
|
||||
// this tree has been here before with check:packets-all, which SKIPped on every build for weeks.
|
||||
// data/people-groups/zoning-records.json carries slug + every field a selector reads — 2,107 records,
|
||||
// ~300 KB — so the gate measures on a clean tree with no network and no generation step.
|
||||
const DEFAULT_RECORDS = join(REPO, 'data/people-groups/zoning-records.json')
|
||||
const LIVE_CACHE = join(REPO, 'library/scripts/posters/out/_records.json')
|
||||
const recordsPath = flag('records', existsSync(LIVE_CACHE) ? LIVE_CACHE : DEFAULT_RECORDS)
|
||||
if (!existsSync(recordsPath)) {
|
||||
console.log(`${TAG}: NOT MEASURED — no records at ${recordsPath}.`)
|
||||
console.log(' The committed fixture is data/people-groups/zoning-records.json; pass --records <file>')
|
||||
console.log(' to use another. Exit 75 rather than 0: "found no records" is not "found nothing wrong".')
|
||||
process.exit(75)
|
||||
}
|
||||
const rawRecords = JSON.parse(readFileSync(recordsPath, 'utf8'))
|
||||
// The fixture wraps its rows so it can carry its own provenance; the live cache is a bare array.
|
||||
const records = Array.isArray(rawRecords) ? rawRecords : (rawRecords.records || [])
|
||||
|
||||
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'))
|
||||
const { buildLegendNodes, leafZones } = await import(pathToFileURL(join(DATA, 'legend-tree.js')).href)
|
||||
const { evaluate } = await import(pathToFileURL(join(DATA, 'selector-evaluator.js')).href)
|
||||
|
||||
// Selectors by id, exactly as generate.mjs:2801-2808 and packet.mjs's allZones do it — leaves do not
|
||||
// carry one, and `deaf`'s is implicit.
|
||||
const selectorsById = new Map()
|
||||
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) {
|
||||
for (const z of clusters[k] || []) selectorsById.set(z.id, z.selector)
|
||||
}
|
||||
selectorsById.set('deaf', { type: 'affinity_bloc', rop1: 'A017' })
|
||||
|
||||
const zones = leafZones(buildLegendNodes(clusters, { records: null }))
|
||||
const zoneSlugs = []
|
||||
for (const z of zones) {
|
||||
const sel = selectorsById.get(z.id)
|
||||
let slugs = []
|
||||
if (sel?.type === 'pg_slugs') slugs = sel.values || []
|
||||
else if (sel) { try { slugs = [...evaluate(sel, records)] } catch (_) { slugs = [] } }
|
||||
zoneSlugs.push([z.id, slugs])
|
||||
}
|
||||
|
||||
const SUPPORTED = ['country_code', 'country_codes', 'wagf_block', 'wagf_region', 'union', 'deaf_predicate',
|
||||
'affinity_bloc', 'pg_slugs']
|
||||
const covered = new Set(zoneSlugs.flatMap(([, s]) => s))
|
||||
const actualOrphans = records.map((r) => r.slug).filter((s) => s && !covered.has(s))
|
||||
|
||||
const declaredOverlaps = new Set((decl.known_geographic_overlaps?.slugs || []).map((o) => o.slug || o))
|
||||
const empty = findEmptyZones(zoneSlugs)
|
||||
const overlaps = findGeographicOverlaps(zoneSlugs, decl.bloc_zones?.ids).filter((o) => !declaredOverlaps.has(o.slug))
|
||||
const orphanDiff = diffOrphans(actualOrphans, decl.expected_orphans?.slugs)
|
||||
const unsupported = findUnsupportedSelectors(selectorsById, SUPPORTED)
|
||||
const oversized = findOversizedZones(zoneSlugs, decl.page_budget?.max_groups)
|
||||
|
||||
console.log(TAG)
|
||||
console.log(` list ${LIST} · ${zones.length} zone(s) · ${records.length} record(s) · ${covered.size} covered`)
|
||||
console.log(` declared: ${(decl.bloc_zones?.ids || []).length} bloc zone(s), `
|
||||
+ `${(decl.expected_orphans?.slugs || []).length} expected orphan(s), `
|
||||
+ `${declaredOverlaps.size} known overlap(s)`)
|
||||
|
||||
const problems = []
|
||||
if (empty.length) problems.push(`A1 ${empty.length} zone(s) resolve to NO groups: ${empty.join(', ')}`)
|
||||
if (overlaps.length) {
|
||||
problems.push(`A2 ${overlaps.length} group(s) in two GEOGRAPHIC zones and not declared: `
|
||||
+ overlaps.slice(0, 8).map((o) => `${o.slug} [${o.zones.join(' + ')}]`).join('; '))
|
||||
}
|
||||
if (orphanDiff.unexpected.length) {
|
||||
problems.push(`A3 ${orphanDiff.unexpected.length} NEW orphan(s) — in the list, in no zone, not declared: `
|
||||
+ orphanDiff.unexpected.slice(0, 12).join(', '))
|
||||
}
|
||||
if (orphanDiff.stale.length) {
|
||||
problems.push(`A3 ${orphanDiff.stale.length} declared orphan(s) are now ZONED, so the declaration is `
|
||||
+ `stale — remove them or the next real drop passes silently: ${orphanDiff.stale.slice(0, 12).join(', ')}`)
|
||||
}
|
||||
if (unsupported.length) {
|
||||
problems.push(`A4 ${unsupported.length} zone(s) use a selector type the evaluator does not support: `
|
||||
+ unsupported.map((b) => `${b.id}(${b.type})`).join(', '))
|
||||
}
|
||||
if (oversized.length) {
|
||||
problems.push(`A5 ${oversized.length} zone(s) exceed the declared page budget `
|
||||
+ `(${decl.page_budget?.max_groups}): ` + oversized.map((o) => `${o.id}=${o.count}`).join(', '))
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`\n${TAG}: FAIL — ${problems.length} problem(s).`)
|
||||
for (const p of problems) console.error(` ✗ ${p}`)
|
||||
console.error('\n Each of these is EITHER a zoning defect OR a declaration that needs updating, and the')
|
||||
console.error(' gate cannot tell which — that is the author\'s call. What it can guarantee is that the')
|
||||
console.error(' zoning and the declaration have not drifted apart silently.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`${TAG}: PASS — the zoning matches every declaration: no empty zone, no undeclared geographic `
|
||||
+ 'overlap, the orphan set is exactly as declared, every selector type is supported, no zone over budget.')
|
||||
|
|
@ -301,6 +301,25 @@ function profileReachesAMap(dir, file) {
|
|||
}
|
||||
return false
|
||||
}
|
||||
/**
|
||||
* STAGE and HEAVY, read from <profile>.meta.json and carried into manifest.json (the Driver, 2026-09-27:
|
||||
* a public-but-unfinished plugin shows under EXPERIMENTAL in the Plugin Studio and the gallery until it works
|
||||
* as a production map). Both surfaces read the MANIFEST, so they cannot disagree, and neither keeps a list.
|
||||
* "stage": "production" | "experimental" default experimental; a bundle is production if ANY of its
|
||||
* profiles says so (a bundle is one folder in the studio)
|
||||
* "heavy": true the element holds live WebGL contexts, so a host must destroy
|
||||
* it when it leaves the screen (Plugin Studio's HEAVY rows)
|
||||
*/
|
||||
function metaStageHeavy(dir, profileName) {
|
||||
try {
|
||||
const meta = JSON.parse(readFileSync(join(dir, `${profileName}.meta.json`), 'utf8'))
|
||||
const corner = meta?.widget?.corner
|
||||
return { stage: meta?.stage === 'production' ? 'production' : 'experimental', heavy: meta?.heavy === true,
|
||||
// "widget": { "corner": "bottom-right" } — a corner widget the Plugin Studio mounts once, fixed, not in a pane
|
||||
widget: ['bottom-right', 'bottom-left', 'top-right', 'top-left'].includes(corner) ? { corner } : null }
|
||||
} catch { return { stage: 'experimental', heavy: false, widget: null } }
|
||||
}
|
||||
|
||||
function metaIsMap(dir, profileName) {
|
||||
try {
|
||||
const meta = JSON.parse(readFileSync(join(dir, `${profileName}.meta.json`), 'utf8'))
|
||||
|
|
@ -1286,8 +1305,12 @@ function scanBundles() {
|
|||
? composesAMapLeaf(dir, profileName, file)
|
||||
: (metaIsMap(dir, profileName) ?? profileReachesAMap(dir, file))
|
||||
|
||||
const { stage: profileStage, heavy: profileHeavy, widget: profileWidget } = metaStageHeavy(dir, profileName)
|
||||
return {
|
||||
profile: profileName,
|
||||
stage: profileStage,
|
||||
...(profileHeavy ? { heavy: true } : {}),
|
||||
...(profileWidget ? { widget: profileWidget } : {}),
|
||||
file,
|
||||
description: description || fileTag || '',
|
||||
element: tag,
|
||||
|
|
@ -1341,6 +1364,7 @@ function scanBundles() {
|
|||
integrity,
|
||||
elements: bundleElements,
|
||||
...(composesUnique.length ? { composes: composesUnique } : {}),
|
||||
stage: profiles.some((p) => p.stage === 'production') ? 'production' : 'experimental',
|
||||
profiles,
|
||||
})
|
||||
}
|
||||
|
|
@ -1643,6 +1667,15 @@ function main() {
|
|||
staged++
|
||||
}
|
||||
}
|
||||
// people-groups/lists/*.json — the ?list= / profile-config `list` prop fetches the chosen list by name
|
||||
// (frontier-500k, frontier, unreached, unengaged), so the whole folder ships. Missing, every non-default
|
||||
// list 404'd on the built tree and drew 0 pins (go-int / rega at a71007f1).
|
||||
const listsSrc = join(pubAssets, 'people-groups', 'lists')
|
||||
if (existsSync(listsSrc)) {
|
||||
const listsDest = join(treeAssets, 'people-groups', 'lists')
|
||||
mkdirSync(listsDest, { recursive: true })
|
||||
for (const f of readdirSync(listsSrc)) { if (!f.endsWith('.json')) continue; cpSync(join(listsSrc, f), join(listsDest, f)); staged++ }
|
||||
}
|
||||
// previews/ + posters/ — the poster walls fetch these PER CARD, lazily, by id
|
||||
// (`${assetsBase}/previews/<id>.webp`, `${assetsBase}/posters/<id>.webp`), so the
|
||||
// name is dynamic and the whole folder ships. They are the bulk of the tree
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
import { computed, provide, inject, watchEffect, watch } from 'vue'
|
||||
import { useSharedMapToken, publishMapToken, fetchMapToken } from './composables/useSharedMapToken.js'
|
||||
import { retryableAsyncComponent } from './composables/retryableAsyncComponent.js'
|
||||
import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale } from './i18n/index.js'
|
||||
import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale, localeFromUrl } from './i18n/index.js'
|
||||
// QA corner: COMPILE-TIME gated (__DOXA_QA__, vite define). In public builds
|
||||
// the flag is false → this whole branch (and the widget's code, including its
|
||||
// platform references) is eliminated from the bundle. Platform builds
|
||||
|
|
@ -158,7 +158,10 @@ const showPinHint = computed(() => config.value?.pinHint !== false)
|
|||
// shipped catalogs; an unknown/absent value leaves the i18n-detected default
|
||||
// (document.documentElement.lang → 'en') untouched.
|
||||
const lang = computed(() => {
|
||||
const raw = (config.value?.locale || config.value?.lang || '').split('-')[0].toLowerCase()
|
||||
// ?lang= / ?locale= on the page is the last rung: an embed opened with ?lang=ar already got Arabic TEXT
|
||||
// (createAppI18n reads the URL) but this component's lang stayed '' so its dir and data language did not
|
||||
// follow (upg100-plugin). profile-config still wins.
|
||||
const raw = (config.value?.locale || config.value?.lang || localeFromUrl() || '').split('-')[0].toLowerCase()
|
||||
return SUPPORTED_LOCALES.includes(raw) ? raw : ''
|
||||
})
|
||||
// CRITICAL: this component is the ROOT of a defineCustomElement. vue-i18n's
|
||||
|
|
|
|||
|
|
@ -95,8 +95,13 @@ const labelW = computed(() => {
|
|||
return Math.max(96, Math.min(260, Math.round((width.value || 640) * 0.34)))
|
||||
})
|
||||
// Room on the right for the printed value when a bar runs long.
|
||||
const VALUE_GUTTER = 74
|
||||
const plotW = computed(() => Math.max(40, (width.value || 640) - labelW.value - VALUE_GUTTER))
|
||||
// The value column fits the LONGEST value label (rega, row 82: a fixed 74px clipped "10,160,000" at the edge
|
||||
// of a 335px card). ~7px per character at the value font, plus the 8px gap and a margin; never under 74.
|
||||
const VALUE_GUTTER = computed(() => {
|
||||
const longest = Math.max(0, ...rows.value.filter((r) => !r.unknown).map((r) => String(props.format(total(r))).length))
|
||||
return Math.max(74, longest * 7 + 16)
|
||||
})
|
||||
const plotW = computed(() => Math.max(40, (width.value || 640) - labelW.value - VALUE_GUTTER.value))
|
||||
|
||||
const rowStep = computed(() => props.barHeight + props.gap)
|
||||
const svgH = computed(() => Math.max(rowStep.value, rows.value.length * rowStep.value + 6))
|
||||
|
|
@ -192,7 +197,10 @@ useShadowStyles(
|
|||
|
||||
<div v-if="!rows.length" class="bc-empty">{{ emptyText }}</div>
|
||||
|
||||
<svg v-else :width="width || 640" :height="svgH" role="img" :aria-label="ariaLabel">
|
||||
<!-- direction:ltr on the SVG: its geometry is left-to-right by construction (labels at x=0, values after
|
||||
the bar end). Under an inherited rtl, text-anchor start flips, so labels ran off the left edge and
|
||||
values back over their bars (rega, row 82, ar). Each label's own text still shapes as Arabic. -->
|
||||
<svg v-else :width="width || 640" :height="svgH" role="img" :aria-label="ariaLabel" direction="ltr" style="direction:ltr">
|
||||
<defs>
|
||||
<!-- Hatching means NOT MEASURED. It must not look like a small bar. -->
|
||||
<pattern :id="`${uid}-hatch`" width="7" height="7" patternUnits="userSpaceOnUse"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
<script setup>
|
||||
/**
|
||||
* ── KIT LANDING 4c-4: MOVED FROM plugin-designs/research-dashboard/ ───────────────────────────
|
||||
* The breadcrumb completes the facet-cascade group the spec scheduled together — rail (4c-3), chips
|
||||
* (4c-2), breadcrumb. It was left behind when the other two moved, which is the whole reason it is
|
||||
* called out here: a group scheduled as one landing was delivered as two, and the leftover is
|
||||
* invisible unless someone re-reads the spec against the tree.
|
||||
*
|
||||
* Like the chip bar, it is mounted by NOBODY today (it was exported from the plugin barrel and
|
||||
* rendered nowhere), so its gates mean "it mounts and degrades", not "it looks right".
|
||||
*/
|
||||
import { tStatic } from '@map/i18n/index.js'
|
||||
const L = (key, en) => tStatic('dashboard.' + key) || en
|
||||
/**
|
||||
|
|
@ -8,7 +18,7 @@ const L = (key, en) => tStatic('dashboard.' + key) || en
|
|||
*/
|
||||
import { inject, computed } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
|
||||
// No research label import: the shell supplies the resolver on dashboardConfig.legend (landing 4b).
|
||||
|
||||
const bus = inject('selectionBus', null)
|
||||
const cfg = inject('dashboardConfig', {})
|
||||
|
|
@ -18,7 +28,14 @@ const filterSet = bus?.filterSet || {}
|
|||
// filter chips do (dashboard.config.js:54-57 — codes must never reach a user-facing menu).
|
||||
// DISPLAY ONLY: `k` is what popTo() acts on and the stored value is never rewritten, so the bus's
|
||||
// byte-match contract is untouched.
|
||||
const { labelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject('searchSourceId', null))
|
||||
// ★ THE SAME RESOLVER, AND THE SAME UNCONFIGURED DEFAULT, AS THE CHIP BAR (4c-2). Identical on
|
||||
// purpose: the breadcrumb and the chips render the same filterSet, so if their defaults differed a
|
||||
// half-configured dashboard would show one value resolved and the other raw, in the same band.
|
||||
// The list join matters for the same reason it did there — a filterSet value is an ARRAY once two
|
||||
// values are selected (spec D1), and `String(['A012','A013'])` prints comma-jammed.
|
||||
// `dataStore` and `searchSourceId` are NOT injected any more: they existed only to construct
|
||||
// useTreeLegendLabels, and an unused inject is a claim about the host.
|
||||
const labelFor = (facet, value) => (cfg?.legend?.labelFor ? cfg.legend.labelFor(facet, value) : (Array.isArray(value) ? value.join(', ') : value))
|
||||
const crumbs = computed(() => Object.entries(filterSet).map(([k, v]) => ({ k, v: String(labelFor(k, v)) })))
|
||||
|
||||
function popTo(k) {
|
||||
|
|
@ -34,6 +34,7 @@ const L = (key, en) => tStatic('dashboard.' + key) || en
|
|||
*/
|
||||
import { inject, ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { formatCount } from '@map/utils/formatCount.js'
|
||||
import { aggregate, lbl, otherLabel } from '@map/composables/useFacetAggregation.js'
|
||||
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '@map/composables/useDashboardSearch.js'
|
||||
// ★ NO RESEARCH COLOUR IMPORTS (kit landing 4b). This component used to import
|
||||
|
|
@ -746,7 +747,7 @@ useShadowStyles(`
|
|||
The four stacked trees below it are People · Place · Language · Religion, in that
|
||||
order — the four axes a researcher navigates to find who has gospel resources. -->
|
||||
<div class="fr-title">{{ L('vertical_legend', 'Vertical Legend') }}</div>
|
||||
<div class="fr-scope" data-scope-count><b>{{ scopeCount.toLocaleString('en-US') }}</b> {{ L('groups_in_scope', 'groups in scope') }}</div>
|
||||
<div class="fr-scope" data-scope-count><b>{{ formatCount(scopeCount) }}</b> {{ L('groups_in_scope', 'groups in scope') }}</div>
|
||||
</div>
|
||||
<div class="fr-body" ref="railEl" @click="onRowClick" @keydown="onRowKey">
|
||||
|
||||
|
|
@ -762,7 +763,7 @@ useShadowStyles(`
|
|||
:tabindex="(r.other || r.zero) ? null : 0"
|
||||
:role="(r.other || r.zero) ? null : 'button'"
|
||||
:aria-pressed="(r.other || r.zero) ? null : (r.active ? 'true' : 'false')"
|
||||
:aria-label="(r.other || r.zero) ? null : ((r.active ? L('remove_filter', 'Remove filter') : L('filter_by', 'Filter by')) + ' ' + r.label + ', ' + r.count.toLocaleString('en-US') + ' ' + L('groups', 'groups') + (r.dashboardOnly ? '. Updates the charts on this dashboard only; it does not filter the list below.' : ''))"
|
||||
:aria-label="(r.other || r.zero) ? null : ((r.active ? L('remove_filter', 'Remove filter') : L('filter_by', 'Filter by')) + ' ' + r.label + ', ' + formatCount(r.count) + ' ' + L('groups', 'groups') + (r.dashboardOnly ? '. Updates the charts on this dashboard only; it does not filter the list below.' : ''))"
|
||||
:title="r.dashboardOnly ? 'Updates this dashboard only — the list below does not filter on ' + r.label : null">
|
||||
<!-- tree trunks: one span per ancestor level, same glyph vocabulary as the
|
||||
research-map's SemanticTreeLegend so the hierarchy is DRAWN, not just padded. -->
|
||||
|
|
@ -781,7 +782,7 @@ useShadowStyles(`
|
|||
<span v-if="r.swatch" class="fr-sw" :style="{ background: r.swatch }" aria-hidden="true"></span>
|
||||
<span class="fr-lbl">{{ r.label }}</span>
|
||||
<span class="fr-bar"><i :style="{ width: Math.max(4, r.frac * 100) + '%' }"></i></span>
|
||||
<span class="fr-cnt">{{ r.count.toLocaleString('en-US') }}</span>
|
||||
<span class="fr-cnt">{{ formatCount(r.count) }}</span>
|
||||
<!-- A VISIBLE mark, not only a title/aria one. A tooltip answers a question the user
|
||||
does not know to ask: the whole defect is that this row LOOKS identical to a row
|
||||
that filters the list. The glyph is what prompts the hover. -->
|
||||
|
|
|
|||
|
|
@ -65,13 +65,27 @@ useShadowStyles(`
|
|||
.mcb.dark:hover:not(:disabled){background:#4e594f;box-shadow:0 2px 8px rgba(0,0,0,0.5);}
|
||||
.mcb.dark.active{background:#4e594f;color:#92b195;}
|
||||
@media(max-width:767px){.mcb{width:36px;height:36px;}}
|
||||
/* shape="square" on a STANDALONE button: their 4px corners, our fill and shadow kept.
|
||||
A button inside .map-toolbar--square is overridden back to 0 by the toolbar's rules. */
|
||||
.mcb--square{ border-radius:4px; }
|
||||
`, 'map-control-button')
|
||||
|
||||
defineProps({
|
||||
isDark: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
active: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false }
|
||||
disabled: { type: Boolean, default: false },
|
||||
/**
|
||||
* 'round' (default) is the 50% circle every map ships. 'square' is for a button used OUTSIDE a
|
||||
* MapToolbar -- inside one, set controls="square" on the TOOLBAR instead and it styles its whole
|
||||
* column, because the engine's square form is a grouped panel and a single button cannot be one.
|
||||
* ★ A STANDALONE SQUARE KEEPS ITS OWN FILL AND SHADOW and takes only the 4px corners, which is
|
||||
* what the engine gives a lone control: `button:focus:only-child { border-radius: inherit }` from
|
||||
* a group whose radius is 4px. Inside a toolbar the panel owns the fill, so the toolbar's rules
|
||||
* strip it back off -- the two rule sets compose rather than compete, and the toolbar wins by
|
||||
* being the more specific selector.
|
||||
*/
|
||||
shape: { type: String, default: 'round' }
|
||||
})
|
||||
|
||||
defineEmits(['click'])
|
||||
|
|
@ -96,7 +110,7 @@ defineEmits(['click'])
|
|||
-->
|
||||
<button
|
||||
class="mcb"
|
||||
:class="{ dark: isDark, active }"
|
||||
:class="{ dark: isDark, active, 'mcb--square': shape === 'square' }"
|
||||
:title="title"
|
||||
:aria-label="title || undefined"
|
||||
:disabled="disabled"
|
||||
|
|
|
|||
|
|
@ -21,17 +21,40 @@
|
|||
profiles that DO mount a geocoder.
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, provide } from 'vue'
|
||||
import { computed, ref, provide } from 'vue'
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
hasGeocoder: { type: Boolean, default: true },
|
||||
/**
|
||||
* 'round' (default) keeps the separate floating circles every map ships today.
|
||||
* 'square' is the ENGINE'S OWN grouped card -- one white panel, 4px corners, a 2px ring and 1px
|
||||
* dividers -- which is the look the Driver asked for by name. Measured from
|
||||
* node_modules/maplibre-gl/dist/maplibre-gl.css, not invented:
|
||||
* .maplibregl-ctrl-group { background:#fff; border-radius:4px }
|
||||
* .maplibregl-ctrl-group:not(:empty) { box-shadow:0 0 0 2px rgba(0,0,0,.1) }
|
||||
* .maplibregl-ctrl-group button { width:29px; height:29px; background:transparent; border:0 }
|
||||
* .maplibregl-ctrl-group button+button { border-top:1px solid #ddd }
|
||||
* ★ SQUARE IS NOT A RADIUS SWAP, which is why it lives here and not only on the button: round
|
||||
* controls are separate boxes with a gap and a shadow each, and the engine's square form is ONE
|
||||
* container that owns the background, the corners and the ring while its buttons own nothing. Set
|
||||
* border-radius:0 on the buttons alone and you get squares floating 8px apart -- neither shape.
|
||||
* ★ THE 29px IS DELIBERATELY NOT PORTED. Ours stay --map-btn-size (capped to 32px in this
|
||||
* toolbar). 29px would shrink the touch target below what this project has already ruled on twice,
|
||||
* so the variant takes the SHAPE and leaves the SIZE. It is therefore not pixel-identical to the
|
||||
* engine's controls, and that is a choice rather than an oversight.
|
||||
*/
|
||||
controls: { type: String, default: 'round' },
|
||||
})
|
||||
|
||||
// The toolbar's element is provided to its controls: MapControlMenu measures it to place
|
||||
// every menu at the toolbar's top, on its inner side, no taller than the control column.
|
||||
const toolbarEl = ref(null)
|
||||
provide('mapToolbar', { el: toolbarEl })
|
||||
// The shape rides the EXISTING inject so every control reads ONE source of truth. The visual work is
|
||||
// done by CSS descendant rules below -- nothing has to pass a matching prop to each button -- but
|
||||
// MapControlMenu measures this column, and a square toolbar is SHORTER (gap:0), so a menu using
|
||||
// matchToolbar gets a shorter box. Anything that needs to know can read it here instead of guessing.
|
||||
provide('mapToolbar', { el: toolbarEl, shape: computed(() => (props.controls === 'square' ? 'square' : 'round')) })
|
||||
|
||||
useShadowStyles(`
|
||||
.map-toolbar {
|
||||
|
|
@ -54,6 +77,50 @@ useShadowStyles(`
|
|||
.map-toolbar .mcb,
|
||||
.map-toolbar .hbg-btn { width: 32px; height: 32px; }
|
||||
.map-toolbar .hbg-ctrl { margin: 10px 0 0 0; } /* drop the 6px left offset so it lines up with the rest */
|
||||
|
||||
/* ── controls="square": the ENGINE'S grouped card, ported ───────────────────────────────── */
|
||||
.map-toolbar--square {
|
||||
gap: 0; /* the group is one panel, not a spaced column */
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); /* .maplibregl-ctrl-group:not(:empty) */
|
||||
}
|
||||
/* The buttons own nothing: no corners, no shadow, no fill. The panel owns all three. */
|
||||
.map-toolbar--square .mcb,
|
||||
.map-toolbar--square .hbg-btn {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
/* button+button -> a 1px divider. Written on the toolbar's CHILDREN rather than on .mcb pairs
|
||||
because some controls wrap their button in an element of their own (.hbg-ctrl), and a divider
|
||||
that only knew about .mcb would vanish exactly where two different control types meet. */
|
||||
.map-toolbar--square > * + * { border-top: 1px solid #ddd; }
|
||||
/* .hbg-ctrl's 10px top margin would tear the panel apart; inside the group it has no business. */
|
||||
.map-toolbar--square .hbg-ctrl { margin: 0; }
|
||||
/* ★ OUTER CORNERS ONLY, AND NO overflow:hidden. Clipping would have been the quick way to round
|
||||
the panel, but it also clips a focus ring on any inner button -- and the engine does not do it:
|
||||
it rounds the first and last buttons instead (button:focus:first-child -> 4px 4px 0 0, :last-child
|
||||
-> 0 0 4px 4px, :only-child -> inherit). Ported as their mechanism. MapControlMenu teleports out of
|
||||
this element into the map area, so clipping would not have hidden a menu -- the focus ring is the
|
||||
real cost, and it is the invisible kind. */
|
||||
.map-toolbar--square > :first-child,
|
||||
.map-toolbar--square > :first-child .mcb,
|
||||
.map-toolbar--square > :first-child .hbg-btn { border-radius: 4px 4px 0 0; }
|
||||
.map-toolbar--square > :last-child,
|
||||
.map-toolbar--square > :last-child .mcb,
|
||||
.map-toolbar--square > :last-child .hbg-btn { border-radius: 0 0 4px 4px; }
|
||||
.map-toolbar--square > :only-child,
|
||||
.map-toolbar--square > :only-child .mcb,
|
||||
.map-toolbar--square > :only-child .hbg-btn { border-radius: 4px; }
|
||||
/* ★ FORCED COLOURS, which our round buttons have never had. The engine ships this pair because a
|
||||
rgba() ring and a #ddd divider both disappear when the user's own palette replaces them, leaving
|
||||
one undifferentiated block where the controls were. */
|
||||
@media (forced-colors: active) {
|
||||
.map-toolbar--square { box-shadow: 0 0 0 2px ButtonText; }
|
||||
.map-toolbar--square > * + * { border-top: 1px solid ButtonText; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Mobile (with geocoder): drop the toolbar well below the search bar so
|
||||
it doesn't sit flush against the search pill. Search = top:10,
|
||||
|
|
@ -62,12 +129,23 @@ useShadowStyles(`
|
|||
/* Mobile (no geocoder): collapse the reserved gap — controls sit flush
|
||||
to the top of the map area with the same 6px inset used on desktop. */
|
||||
.map-toolbar.map-toolbar--no-geocoder { top: 6px; gap: 6px; }
|
||||
/* ★ THE SQUARE PANEL MUST NOT GET ITS GAPS BACK HERE, and it would have. A media query adds NO
|
||||
specificity, so the .map-toolbar gap:6px rule above is (0,1,0) exactly like the
|
||||
.map-toolbar--square gap:0 rule -- and it comes LATER in the sheet, so it won. On any phone a
|
||||
square toolbar would have fallen back into six separate buttons inside one ringed panel: the
|
||||
shape breaking precisely where the Driver is most likely to look at it.
|
||||
The --no-geocoder pair is (0,2,0) and outranked the variant regardless of order, so it needs the
|
||||
two-class form to answer it. Same lesson as the goal cards' gap-vs-margin: a ported mechanism is
|
||||
only ported if it still WINS. */
|
||||
.map-toolbar--square,
|
||||
.map-toolbar--square.map-toolbar--no-geocoder { gap: 0; }
|
||||
}
|
||||
`, 'map-toolbar')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="toolbarEl" class="map-toolbar" :class="{ 'map-toolbar--no-geocoder': !hasGeocoder }">
|
||||
<div ref="toolbarEl" class="map-toolbar"
|
||||
:class="{ 'map-toolbar--no-geocoder': !hasGeocoder, 'map-toolbar--square': controls === 'square' }">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
* CROSS-PANE COMMS — this wall NEVER imports a map. It dispatches a window
|
||||
* CustomEvent scoped by instanceId, so several embeds on one page never cross-talk.
|
||||
*/
|
||||
import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { tStatic } from '../../i18n/index.js'
|
||||
|
||||
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
|
||||
|
|
@ -284,6 +284,24 @@ const toggleSection = (name) => {
|
|||
|
||||
const lastShown = ref(null)
|
||||
const missing = ref({})
|
||||
// FORGET the shown card when the wall's view goes inactive (dx-shepherd, row 79). A composite marks its
|
||||
// inactive pane `inert` (the PinHint contract); after "Show zone" -> Map -> back, the same card must SHOW
|
||||
// again, not toggle off a selection the reader can no longer see. Watches the ancestor chain, through
|
||||
// shadow roots, for inert/hidden/aria-hidden; clears lastShown only (the map keeps its selection).
|
||||
const wallEl = ref(null)
|
||||
let paneMOs = []
|
||||
onMounted(() => {
|
||||
const chain = []
|
||||
for (let n = wallEl.value; n; n = n.parentNode || n.host || null) if (n.nodeType === 1) chain.push(n)
|
||||
const inactive = () => chain.some((a) => a.hasAttribute('inert') || a.hasAttribute('hidden') || a.getAttribute('aria-hidden') === 'true')
|
||||
if (typeof MutationObserver === 'undefined') return
|
||||
for (const a of chain) {
|
||||
const mo = new MutationObserver(() => { if (inactive()) lastShown.value = null })
|
||||
mo.observe(a, { attributes: true, attributeFilter: ['inert', 'hidden', 'aria-hidden'] })
|
||||
paneMOs.push(mo)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => { for (const mo of paneMOs) mo.disconnect(); paneMOs = [] })
|
||||
|
||||
function showOnMap(item) {
|
||||
// TOGGLE, not a one-way set: a control the user can turn on but not off reads as
|
||||
|
|
@ -949,7 +967,7 @@ useShadowStyles(
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wall" :class="{ 'is-dark': isDark, 'wall--embedded': embedded }">
|
||||
<div ref="wallEl" class="wall" :class="{ 'is-dark': isDark, 'wall--embedded': embedded }">
|
||||
<h2 class="wall-h">{{ heading }}</h2>
|
||||
<div v-if="tag" class="wall-tag">{{ tag }}</div>
|
||||
<p v-if="intro" class="wall-sub" v-html="intro"></p>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@
|
|||
*
|
||||
* Shadow-DOM safe: pure data, no DOM/style. Reuses read-only @map config without editing it.
|
||||
*/
|
||||
// KIT LANDING 4d-2: moved from plugin-designs/research-dashboard/src/composables/. It needed no
|
||||
// adapter — every import was already @map/ — and it injects profileConfig in a try/catch because it
|
||||
// can be called outside setup, which is the one property the move had to preserve.
|
||||
|
||||
import { ref, shallowRef, inject } from 'vue'
|
||||
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
|
||||
import sourcesConfig from '@map/api/sources.json'
|
||||
|
|
@ -352,8 +352,16 @@ export function useDashboardLayout(options = {}) {
|
|||
// Read per drag rather than cached: a host can flip dir between gestures (our own composites
|
||||
// set it on the custom-element host from the runtime locale), and a cached sign would be wrong
|
||||
// for the rest of the session.
|
||||
// ★ PROBE THE HANDLE, NOT THE SHELL. The sign has to match the element the GRID lays out with,
|
||||
// and the handle is a grid child: whatever direction the grid resolves, the handle inherits. The
|
||||
// shell is one node further out, so a host that sets dir between the two — or a shellRef that is
|
||||
// not the grid itself — gives a direction that disagrees with the mirroring the user can see.
|
||||
// Falls back to the shell, then to ltr, so a detached handle cannot throw mid-gesture.
|
||||
const rtl = (() => {
|
||||
try { return getComputedStyle(unref(shellRef)).direction === 'rtl' } catch (_) { return false }
|
||||
for (const node of [e.currentTarget, unref(shellRef)]) {
|
||||
try { const d = node && getComputedStyle(node).direction; if (d) return d === 'rtl' } catch (_) { /* next */ }
|
||||
}
|
||||
return false
|
||||
})()
|
||||
const xSign = rtl ? -1 : 1
|
||||
const handleEl = e.currentTarget
|
||||
|
|
@ -549,7 +557,8 @@ export function useDashboardLayout(options = {}) {
|
|||
// watched. aria-valuenow reports the same ref for both, so a disagreement here would also
|
||||
// make the announced value move the wrong way.
|
||||
let kSign = 1
|
||||
try { if (getComputedStyle(unref(shellRef)).direction === 'rtl') kSign = -1 } catch (_) { /* no element */ }
|
||||
// Same node choice as the pointer path above, for the same reason.
|
||||
try { for (const node of [e.currentTarget, unref(shellRef)]) { const d = node && getComputedStyle(node).direction; if (d) { if (d === 'rtl') kSign = -1; break } } } catch (_) { /* no element */ }
|
||||
if (K === 'ArrowLeft') dir = -1 * kSign; else if (K === 'ArrowRight') dir = 1 * kSign
|
||||
}
|
||||
if (dir) { e.preventDefault(); stepResize(axis, dir); return }
|
||||
|
|
|
|||
|
|
@ -469,7 +469,12 @@ export function useMapInstance({
|
|||
accessToken = window.MAP_TOKEN
|
||||
}
|
||||
if (!accessToken) {
|
||||
console.error('No Mapbox access token provided. Pass "tk" in profile-config JSON or set window.MAP_TOKEN.')
|
||||
// ONE warn per page, not an error per map: the UI already says "Map unavailable", and a page with
|
||||
// several maps logged this 7x as console.error, which breaks the 0-errors bar (dx-shepherd, TKWARN).
|
||||
if (typeof window !== 'undefined' && !window.__doxaNoTokenWarned) {
|
||||
window.__doxaNoTokenWarned = true
|
||||
console.warn('No Mapbox access token provided. Pass "tk" in profile-config JSON or set window.MAP_TOKEN.')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!accessToken.startsWith('pk.')) {
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "الوصول بلغتهم، حسب استخدام الإنترنت في البلد",
|
||||
"aria": "رسوم بيانية",
|
||||
"bandCaveat": "استخدام الإنترنت رقم البلد (البنك الدولي)، وليس رقم المجموعة أبدًا.",
|
||||
"gapSub": "أكبر 8 من المعروضة حسب عدد السكان. انقر إحداها لفتحها.",
|
||||
"gapTake": "هذه الـ{k} وحدها تضم {people} شخص.",
|
||||
"gapTitle": "أكبر المجموعات العرقية التي لا شيء لها بلغتها",
|
||||
"groups": "المجموعات",
|
||||
"groupsN": "{n} مجموعة",
|
||||
"measure": "العدّ",
|
||||
"messagingTop": "تطبيق المراسلة الأول، حسب البلدان ضمن النطاق",
|
||||
"notMeasured": "غير مقيس",
|
||||
"ntSub": "لغات المجموعات المعروضة التي لم يُعثر لها على عهد جديد، حسب عدد متحدثيها. الأحمر: لا شيء؛ الأصفر: شيء ما لكن بلا عهد جديد.",
|
||||
"ntTake": "{n} لغة هنا لم يُعثر لها على عهد جديد، يتحدثها {people} شخص.",
|
||||
"ntTitle": "أي اللغات أحوج إلى العهد الجديد؟",
|
||||
"people": "الأشخاص",
|
||||
"peopleN": "{n} شخص",
|
||||
"q1sub": "عمود لكل مستوى وصول، للمجموعات العرقية المعروضة.",
|
||||
"q1take": "{n} من {total} مجموعة عرقية معروضة لا شيء لها بلغتها.",
|
||||
"q1title": "ماذا لديهم بلغتهم؟",
|
||||
"q2sub": "المجموعات العرقية التي لا شيء لها بلغتها، حسب نسبة الاتصال بالإنترنت في بلدها.",
|
||||
"q2take": "{online} من الـ{n} التي لا شيء لها تعيش في بلدان يستخدم فيها 60% أو أكثر الإنترنت.",
|
||||
"q2title": "هل يمكن للإنترنت إيصاله إلى من لا شيء لهم؟",
|
||||
"socialTop": "منصة التواصل الأولى، حسب البلدان ضمن النطاق",
|
||||
"unitCountries": "بلدان",
|
||||
"unitGroups": "مجموعة عرقية",
|
||||
|
|
@ -49,6 +61,18 @@
|
|||
"translatedBy": "ترجمة",
|
||||
"wayIn": "المدخل"
|
||||
},
|
||||
"combos": {
|
||||
"audioOnline": "يسمعون ولا يقرؤون، البلد متصل",
|
||||
"deaf": "يوجد جسر بلغة الإشارة",
|
||||
"digitalNow": "لا شيء بلغتهم، البلد متصل بنسبة 60%+",
|
||||
"indiaChinaGap": "الهند والصين: لا شيء بلغتهم",
|
||||
"none": "الكل — اختر عامل تصفية أو تركيبة",
|
||||
"offlineGap": "لا شيء بلغتهم، البلد غير متصل غالبًا",
|
||||
"selection": "اختيارك",
|
||||
"telegram": "لا شيء بلغتهم، بلد تيليغرام",
|
||||
"try": "جرّب تركيبة",
|
||||
"whatsapp": "لا شيء بلغتهم، بلد واتساب"
|
||||
},
|
||||
"detail": {
|
||||
"hear": "يشاهدون أو يسمعون ولا يقرؤون",
|
||||
"none": "لم يُعثر على شيء",
|
||||
|
|
@ -107,6 +131,8 @@
|
|||
"count": "{n} من {total} مجموعة عرقية",
|
||||
"empty": "لا توجد مجموعة عرقية تطابق عوامل التصفية هذه.",
|
||||
"exported": "تم تصدير {n} صف (نُسخت ونُزّلت).",
|
||||
"openCapped": "يفتح أول {n} من المحدد.",
|
||||
"openInGospel": "فتح في موارد الإنجيل",
|
||||
"selectRow": "اختيار {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
@ -186,6 +212,14 @@
|
|||
"languagesHere": "لغات هنا",
|
||||
"legendTitle": "بلغتهم",
|
||||
"levels": "عائلة › فرع › لغة › لهجة. تُظهر العائلة أو الفرع أسوأ لون بداخله. وتُظهر اللهجة لون لغتها بدرجة أفتح: تُفهرس الموارد لكل لغة لا لكل لهجة. انقر دائرة لحصر اللوحة فيها.",
|
||||
"poster": "ملصق",
|
||||
"poster3ft": "3 × 3 قدم",
|
||||
"poster4ft": "4 × 4 قدم",
|
||||
"posterOmitted": "حُذف {n} اسمًا حيث كانت ستتداخل مع غيرها؛ كل دائرة مرسومة.",
|
||||
"posterPrint": "طباعة",
|
||||
"posterSizeAria": "حجم الملصق",
|
||||
"posterSvg": "تنزيل SVG",
|
||||
"posterTitle": "الوصول إلى الإنجيل، عائلة لغوية تلو الأخرى",
|
||||
"sources": "الشجرة: Glottolog (CC BY 4.0). الموارد: eBible وFaith Comes By Hearing وScripture Earth وPNG.Bible وفيلم يسوع وGlobal Recordings وغيرها، حسب ISO 639-3.",
|
||||
"st": {
|
||||
"g": "وُجد العهد الجديد أو الكتاب المقدس كاملًا",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
},
|
||||
"upg100WallHeading": "قائمتي UPG 100 — قوائم الصلاة والملصقات",
|
||||
"upg100WallTag": "تعبئة للصلاة",
|
||||
"upg100WallIntro": "وزّع مهمة الصلاة في مجموعات من خمسين ومئة — كما أطعم يسوع الخمسة آلاف (لوقا ٩:١٤). كل ملصق هو قائمة من 100 يمكنك أن تصلي بها وتطبعها وتعلّقها على الحائط — مصلّيًا لأجلهم <strong>باسم شعبهم</strong>.",
|
||||
"upg100WallIntroA": "وزّع مهمة الصلاة في مجموعات من خمسين ومئة — كما أطعم يسوع الخمسة آلاف (لوقا ٩:١٤). كل ملصق هو قائمة من 100 يمكنك أن تصلي بها وتطبعها وتعلّقها على الحائط — مصلّيًا لأجلهم ",
|
||||
"upg100WallIntroEmph": "باسم شعبهم",
|
||||
"upg100WallIntroB": ".",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"sendMap": "خريطة الإرسال",
|
||||
"flowerMesh": "زهرة الشبكة",
|
||||
"langCluster": "عناقيد اللغات",
|
||||
"flowers": "الزهور"
|
||||
"flowers": "الزهور",
|
||||
"mobilize": "التعبئة",
|
||||
"campaigns": "الحملات"
|
||||
},
|
||||
"hints": {
|
||||
"cockpit": "مركز التصفية المتقاطعة",
|
||||
|
|
@ -172,5 +174,80 @@
|
|||
"sm": {
|
||||
"counts": "{u} شعبًا غير مُبشَّر · {s} من المُرسِلين الذين تم الوصول إليهم",
|
||||
"noSenders": "لا يوجد مُرسِلون مؤهّلون تم الوصول إليهم ولهم مركز دولة معروف لهذا الهدف."
|
||||
},
|
||||
"about": {
|
||||
"button": "كيف يعمل",
|
||||
"title": "تعبئة الجيران القريبين",
|
||||
"close": "إغلاق",
|
||||
"why": "المؤمنون الأقدر على الوصول إلى شعب غير مبلَغ هم عادةً الأقرب إليه لغةً وثقافة. تجدهم هذه الأداة لكل مجموعة غير مبلَغة، باستخدام مقياسَي E وP لرالف د. وينتر، لتخطط مَن تعبّئ، وبأي لغة، وأين.",
|
||||
"eTitle": "مقياس E: المسافة التي يجب أن يعبرها الشاهد",
|
||||
"pTitle": "مقياس P: المسافة بين شعب وكنيسة",
|
||||
"dataTitle": "البيانات وحدودها",
|
||||
"data": "المبلَغ يعني أن نسبة الإنجيليين 2% على الأقل (تعريف IMB). تُقدَّر اللغة والثقافة من رمز اللغة وتصنيف الشعوب لدى IMB (المجموعة، الكتلة، العائلة اللغوية): مؤشرات عامة جيدة، لا تقييمات ميدانية. أعداد المؤمنين تقديرية من السكان وفئات نسب الإنجيليين؛ استخدمها لتقدير حجم الجمهور لا كأعداد دقيقة.",
|
||||
"source": "بيانات الشعوب: peoplegroups.org (IMB)، people_groups.csv. مقياسا E وP: رالف د. وينتر، مؤتمر لوزان، 1974."
|
||||
},
|
||||
"e": {
|
||||
"short1": "E-1 · اللغة نفسها",
|
||||
"short2": "E-2 · ثقافة قريبة",
|
||||
"short3": "E-3 · قريب، ثقافة أخرى",
|
||||
"title1": "E-1 · يتكلمون اللغة نفسها",
|
||||
"title2": "E-2 · ثقافة قريبة",
|
||||
"title3": "E-3 · قريبون، ثقافة مختلفة",
|
||||
"long1": "مؤمنون يتكلمون لغة هذه المجموعة. يمكنهم مشاركة الإنجيل دون عبور حاجز اللغة، ويمكن لحملة بهذه اللغة أن تعبّئهم.",
|
||||
"long2": "مؤمنون من شعب قريب: المجموعة الشعبية نفسها، أو العائلة اللغوية نفسها ضمن الكتلة نفسها. خطوة ثقافية أقصر من البدء من الصفر.",
|
||||
"long3": "مؤمنون يعيشون بالقرب (ضمن 600 كم أو في الدولة نفسها) لكن ثقافتهم مختلفة. يشاركونهم المنطقة لا اللغة."
|
||||
},
|
||||
"p": {
|
||||
"title0": "لم يُعثر على جيران مبلَغين",
|
||||
"title1": "يوجد مؤمنون بلغته",
|
||||
"title2": "أقرب المؤمنين من ثقافة قريبة",
|
||||
"title3": "أقرب المؤمنين من ثقافة بعيدة",
|
||||
"long0": "لا توجد مجموعة مبلَغة تطابق هذا الشعب لغةً أو ثقافةً أو مسافةً في هذه البيانات.",
|
||||
"long1": "في مكان ما، تتكلم مجموعة مبلَغة لغة هذا الشعب. تعبئتهم هي الطريق الأكثر مباشرة.",
|
||||
"long2": "لا توجد مجموعة مبلَغة تتكلم هذه اللغة، لكن لدى شعوب قريبة مؤمنون.",
|
||||
"long3": "لا يوجد بالقرب إلا مؤمنون من ثقافة مختلفة. الوصول إلى هذا الشعب يحتاج إلى عاملين عبر الثقافات."
|
||||
},
|
||||
"res": {
|
||||
"bible": "الكتاب المقدس",
|
||||
"film": "فيلم يسوع",
|
||||
"audio": "صوتيات",
|
||||
"radio": "راديو"
|
||||
},
|
||||
"mob": {
|
||||
"legend": "مفتاح الخريطة",
|
||||
"legendUnreached": "مجموعة غير مبلَغة",
|
||||
"searchPlaceholder": "ابحث عن مجموعة غير مبلَغة أو دولة أو لغة",
|
||||
"pFilter": "تصفية حسب مستوى P",
|
||||
"pAll": "الكل",
|
||||
"loading": "جارٍ تحميل الشعوب…",
|
||||
"intro": "اختر مجموعة غير مبلَغة (الأكبر أولًا)، أو انقر نقطة حمراء على الخريطة، لترى مَن تعبّئ.",
|
||||
"noMatch": "لا توجد نتائج.",
|
||||
"back": "كل المجموعات",
|
||||
"people": "{n} نسمة",
|
||||
"believersNear": "نحو {n} مؤمن بين الجيران أدناه.",
|
||||
"resources": "الموارد بلغته",
|
||||
"export": "تصدير شرائح الجمهور (CSV)",
|
||||
"openBridge": "خطّط لحملة بلغة {lang}",
|
||||
"samePeople": "الشعب نفسه",
|
||||
"cardStats": "~{b} مؤمن · على بعد {km} كم",
|
||||
"noNeighbors": "لا يوجد جيران مبلَغون في هذه البيانات.",
|
||||
"caveat": "تقديرات من بيانات peoplegroups.org. تحقّق من الواقع المحلي قبل التصرف.",
|
||||
"loadError": "تعذّر تحميل بيانات الشعوب: {message}"
|
||||
},
|
||||
"camp": {
|
||||
"intro": "لغات الجسر: لغات يتكلمها مؤمنون وشعوب غير مبلَغة. حملة واحدة بهذه اللغة، موجهة إلى حيث يعيش المؤمنون، يمكنها أن تعبّئهم للوصول إلى غير المبلَغين بلغتهم.",
|
||||
"searchPlaceholder": "ابحث عن لغة أو دولة",
|
||||
"minBelievers": "على الأقل",
|
||||
"exportAll": "تصدير القائمة (CSV)",
|
||||
"unreachedPeople": "{n} غير مبلَغ",
|
||||
"believers": "~{n} مؤمن",
|
||||
"believersIn": "مؤمنون في: {list}",
|
||||
"back": "كل اللغات",
|
||||
"bigBelievers": "مؤمن للتعبئة ({g} مجموعة، {c} دولة)",
|
||||
"bigUnreached": "غير مبلَغ للوصول إليهم ({g} مجموعة، {c} دولة)",
|
||||
"export": "تصدير هذه الحملة (CSV)",
|
||||
"whereToRun": "أين تُطلقها: المؤمنون حسب الدولة",
|
||||
"believersInGroups": "~{n} في {g} مجموعة",
|
||||
"whoTheyReach": "مَن يمكنهم الوصول إليه"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "متعدد العروض",
|
||||
"cap2": "إعداد مسبق",
|
||||
"cap1": "مفرد",
|
||||
"stageExperimental": "تجريبي",
|
||||
"toggleList": "إظهار القائمة أو إخفاؤها",
|
||||
"openViews": "فتح قائمة العروض",
|
||||
"expandList": "توسيع القائمة",
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Zugang in eigener Sprache, nach Internetnutzung im Land",
|
||||
"aria": "Diagramme",
|
||||
"bandCaveat": "Die Internetnutzung ist die Zahl des Landes (Weltbank), nie die der Gruppe.",
|
||||
"gapSub": "Die 8 größten der angezeigten, nach Bevölkerung. Klicken Sie eine an, um sie zu öffnen.",
|
||||
"gapTake": "Allein diese {k} sind {people} Menschen.",
|
||||
"gapTitle": "Die größten Volksgruppen ohne etwas in ihrer eigenen Sprache",
|
||||
"groups": "Gruppen",
|
||||
"groupsN": "{n} Gruppen",
|
||||
"measure": "Zählen",
|
||||
"messagingTop": "Top-Messenger nach Land im Auswahlbereich",
|
||||
"notMeasured": "Nicht gemessen",
|
||||
"ntSub": "Sprachen der angezeigten Gruppen ohne gefundenes Neues Testament, nach Zahl der Sprecher. Rot: gar nichts; Gelb: etwas, aber kein Neues Testament.",
|
||||
"ntTake": "{n} Sprachen hier haben kein gefundenes Neues Testament, gesprochen von {people} Menschen.",
|
||||
"ntTitle": "Welche Sprachen brauchen das Neue Testament am meisten?",
|
||||
"people": "Menschen",
|
||||
"peopleN": "{n} Menschen",
|
||||
"q1sub": "Ein Balken je Zugangsstufe, für die angezeigten Volksgruppen.",
|
||||
"q1take": "{n} von {total} angezeigten Volksgruppen haben nichts in ihrer eigenen Sprache.",
|
||||
"q1title": "Was haben sie in ihrer eigenen Sprache?",
|
||||
"q2sub": "Volksgruppen ohne etwas in ihrer Sprache, danach, wie viel ihres Landes online ist.",
|
||||
"q2take": "{online} der {n} ohne etwas leben in Ländern, in denen 60 % oder mehr das Internet nutzen.",
|
||||
"q2title": "Könnte das Internet es zu denen ohne etwas bringen?",
|
||||
"socialTop": "Top-Social-Plattform nach Land im Auswahlbereich",
|
||||
"unitCountries": "Länder",
|
||||
"unitGroups": "Volksgruppen",
|
||||
|
|
@ -49,6 +61,18 @@
|
|||
"translatedBy": "Übersetzt von",
|
||||
"wayIn": "Zugang"
|
||||
},
|
||||
"combos": {
|
||||
"audioOnline": "Hören, aber nicht lesen, Land online",
|
||||
"deaf": "Es gibt eine Gebärdensprach-Brücke",
|
||||
"digitalNow": "Nichts in ihrer Sprache, Land zu 60 %+ online",
|
||||
"indiaChinaGap": "Indien und China: nichts in ihrer Sprache",
|
||||
"none": "Alles – wählen Sie einen Filter oder eine Kombination",
|
||||
"offlineGap": "Nichts in ihrer Sprache, Land überwiegend offline",
|
||||
"selection": "Ihre Auswahl",
|
||||
"telegram": "Nichts in ihrer Sprache, Telegram-Land",
|
||||
"try": "Kombination ausprobieren",
|
||||
"whatsapp": "Nichts in ihrer Sprache, WhatsApp-Land"
|
||||
},
|
||||
"detail": {
|
||||
"hear": "Sehen oder hören, nicht lesen",
|
||||
"none": "Nichts gefunden",
|
||||
|
|
@ -107,6 +131,8 @@
|
|||
"count": "{n} von {total} Volksgruppen",
|
||||
"empty": "Keine Volksgruppe entspricht diesen Filtern.",
|
||||
"exported": "{n} Zeilen exportiert (kopiert und heruntergeladen).",
|
||||
"openCapped": "Öffnet die ersten {n} ausgewählten.",
|
||||
"openInGospel": "In Evangeliumsressourcen öffnen",
|
||||
"selectRow": "{name} auswählen"
|
||||
},
|
||||
"headline": {
|
||||
|
|
@ -186,6 +212,14 @@
|
|||
"languagesHere": "Sprachen hier",
|
||||
"legendTitle": "In ihrer eigenen Sprache",
|
||||
"levels": "Familie › Zweig › Sprache › Dialekt. Eine Familie oder ein Zweig zeigt die schlechteste Farbe darin. Ein Dialekt zeigt die Farbe seiner Sprache, heller: Ressourcen werden pro Sprache erfasst, nicht pro Dialekt. Klicken Sie auf einen Kreis, um die Übersicht darauf einzugrenzen.",
|
||||
"poster": "Poster",
|
||||
"poster3ft": "3 × 3 ft",
|
||||
"poster4ft": "4 × 4 ft",
|
||||
"posterOmitted": "{n} Namen weggelassen, wo sie einen anderen überdecken würden; jeder Kreis ist gezeichnet.",
|
||||
"posterPrint": "Drucken",
|
||||
"posterSizeAria": "Postergröße",
|
||||
"posterSvg": "SVG herunterladen",
|
||||
"posterTitle": "Zugang zum Evangelium, Sprachfamilie für Sprachfamilie",
|
||||
"sources": "Baum: Glottolog (CC BY 4.0). Ressourcen: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, JESUS-Film, Global Recordings und weitere, nach ISO 639-3.",
|
||||
"st": {
|
||||
"g": "Neues Testament oder ganze Bibel gefunden",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
},
|
||||
"upg100WallHeading": "Meine UPG-100-Liste — Gebetslisten und Poster",
|
||||
"upg100WallTag": "Gebetsmobilisierung",
|
||||
"upg100WallIntro": "Teilen Sie die Gebetsaufgabe in Gruppen zu fünfzig und hundert auf — so wie Jesus die 5.000 speiste (Lukas 9,14). Jedes Poster ist eine 100er-Liste, die Sie beten, drucken und an die Wand hängen können — und für sie <strong>mit dem Namen ihrer Volksgruppe</strong> beten.",
|
||||
"upg100WallIntroA": "Teilen Sie die Gebetsaufgabe in Gruppen zu fünfzig und hundert auf — so wie Jesus die 5.000 speiste (Lukas 9,14). Jedes Poster ist eine 100er-Liste, die Sie beten, drucken und an die Wand hängen können — und für sie ",
|
||||
"upg100WallIntroEmph": "mit dem Namen ihrer Volksgruppe",
|
||||
"upg100WallIntroB": " beten.",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"sendMap": "Sendekarte",
|
||||
"flowerMesh": "Blütennetz",
|
||||
"langCluster": "Sprachcluster",
|
||||
"flowers": "Blüten"
|
||||
"flowers": "Blüten",
|
||||
"mobilize": "Mobilisieren",
|
||||
"campaigns": "Kampagnen"
|
||||
},
|
||||
"hints": {
|
||||
"cockpit": "Crossfilter-Zentrale",
|
||||
|
|
@ -172,5 +174,80 @@
|
|||
"sm": {
|
||||
"counts": "{u} UPGs · {s} erreichte Sender",
|
||||
"noSenders": "Keine geeigneten erreichten Sender mit bekanntem Länderschwerpunkt für dieses Ziel."
|
||||
},
|
||||
"about": {
|
||||
"button": "So funktioniert es",
|
||||
"title": "Nahe Nachbarn mobilisieren",
|
||||
"close": "Schließen",
|
||||
"why": "Die Gläubigen, die ein unerreichtes Volk am besten erreichen können, stehen ihm meist in Sprache und Kultur am nächsten. Dieses Werkzeug findet sie für jede unerreichte Volksgruppe mit Ralph D. Winters E-Skala und P-Skala, damit Sie planen können, wen Sie mobilisieren, in welcher Sprache und wo.",
|
||||
"eTitle": "E-Skala: die Distanz, die ein Zeuge überwinden muss",
|
||||
"pTitle": "P-Skala: die Distanz zwischen einem Volk und einer Gemeinde",
|
||||
"dataTitle": "Daten und Grenzen",
|
||||
"data": "Erreicht heißt mindestens 2 % Evangelikale (IMB-Definition). Sprache und Kultur werden aus dem Sprachcode und der IMB-Völkertaxonomie (Cluster, Affinitätsblock, Sprachfamilie) abgeleitet: gute öffentliche Näherungswerte, keine Feldbewertungen. Die Zahl der Gläubigen ist aus Bevölkerung und Evangelikalen-Spannen geschätzt; sie dient zur Größenabschätzung eines Publikums, nicht als Zählung.",
|
||||
"source": "Volksgruppendaten: peoplegroups.org (IMB), people_groups.csv. E-Skala und P-Skala: Ralph D. Winter, Lausanner Kongress, 1974."
|
||||
},
|
||||
"e": {
|
||||
"short1": "E-1 · gleiche Sprache",
|
||||
"short2": "E-2 · nahe Kultur",
|
||||
"short3": "E-3 · in der Nähe, andere Kultur",
|
||||
"title1": "E-1 · Sie sprechen dieselbe Sprache",
|
||||
"title2": "E-2 · Eine nahe Kultur",
|
||||
"title3": "E-3 · In der Nähe, eine andere Kultur",
|
||||
"long1": "Gläubige, die die Sprache dieser Gruppe sprechen. Sie können das Evangelium ohne Sprachbarriere weitergeben, und eine Kampagne in dieser Sprache kann sie mobilisieren.",
|
||||
"long2": "Gläubige aus einem verwandten Volk: gleiches Völkercluster oder gleiche Sprachfamilie im selben Affinitätsblock. Ein kürzerer kultureller Schritt als ein Neuanfang.",
|
||||
"long3": "Gläubige in der Nähe (im Umkreis von 600 km oder im selben Land) mit anderer Kultur. Sie teilen die Region, nicht die Sprache."
|
||||
},
|
||||
"p": {
|
||||
"title0": "Keine erreichten Nachbarn gefunden",
|
||||
"title1": "Es gibt Gläubige in seiner Sprache",
|
||||
"title2": "Die nächsten Gläubigen sind eine nahe Kultur",
|
||||
"title3": "Die nächsten Gläubigen sind eine ferne Kultur",
|
||||
"long0": "In diesen Daten passt keine erreichte Gruppe nach Sprache, Kultur oder Entfernung zu diesem Volk.",
|
||||
"long1": "Irgendwo spricht eine erreichte Gruppe die Sprache dieses Volkes. Sie zu mobilisieren ist der direkteste Weg.",
|
||||
"long2": "Keine erreichte Gruppe spricht diese Sprache, aber verwandte Völker haben Gläubige.",
|
||||
"long3": "In der Nähe gibt es nur Gläubige anderer Kultur. Dieses Volk zu erreichen braucht interkulturelle Mitarbeiter."
|
||||
},
|
||||
"res": {
|
||||
"bible": "Bibel",
|
||||
"film": "JESUS-Film",
|
||||
"audio": "Audio",
|
||||
"radio": "Radio"
|
||||
},
|
||||
"mob": {
|
||||
"legend": "Kartenlegende",
|
||||
"legendUnreached": "Unerreichte Gruppe",
|
||||
"searchPlaceholder": "Unerreichte Gruppe, Land oder Sprache suchen",
|
||||
"pFilter": "Nach P-Stufe filtern",
|
||||
"pAll": "Alle",
|
||||
"loading": "Volksgruppen werden geladen…",
|
||||
"intro": "Wählen Sie eine unerreichte Gruppe (die größten zuerst) oder klicken Sie auf einen roten Punkt, um zu sehen, wen Sie mobilisieren können.",
|
||||
"noMatch": "Keine Treffer.",
|
||||
"back": "Alle Gruppen",
|
||||
"people": "{n} Menschen",
|
||||
"believersNear": "Etwa {n} Gläubige unter den Nachbarn unten.",
|
||||
"resources": "Ressourcen in seiner Sprache",
|
||||
"export": "Zielgruppensegmente exportieren (CSV)",
|
||||
"openBridge": "Kampagne auf {lang} planen",
|
||||
"samePeople": "gleiches Volk",
|
||||
"cardStats": "~{b} Gläubige · {km} km entfernt",
|
||||
"noNeighbors": "Keine erreichten Nachbarn in diesen Daten.",
|
||||
"caveat": "Schätzungen aus Daten von peoplegroups.org. Prüfen Sie die Lage vor Ort, bevor Sie handeln.",
|
||||
"loadError": "Die Volksgruppendaten konnten nicht geladen werden: {message}"
|
||||
},
|
||||
"camp": {
|
||||
"intro": "Brückensprachen: Sprachen, die sowohl Gläubige als auch unerreichte Völker sprechen. Eine Kampagne in dieser Sprache, dort ausgespielt, wo die Gläubigen leben, kann sie mobilisieren, die Unerreichten in ihrer eigenen Sprache zu erreichen.",
|
||||
"searchPlaceholder": "Sprache oder Land suchen",
|
||||
"minBelievers": "Mindestens",
|
||||
"exportAll": "Liste exportieren (CSV)",
|
||||
"unreachedPeople": "{n} unerreicht",
|
||||
"believers": "~{n} Gläubige",
|
||||
"believersIn": "Gläubige in: {list}",
|
||||
"back": "Alle Sprachen",
|
||||
"bigBelievers": "Gläubige zum Mobilisieren ({g} Gruppen, {c} Länder)",
|
||||
"bigUnreached": "Unerreichte zum Erreichen ({g} Gruppen, {c} Länder)",
|
||||
"export": "Diese Kampagne exportieren (CSV)",
|
||||
"whereToRun": "Wo sie laufen soll: Gläubige nach Land",
|
||||
"believersInGroups": "~{n} in {g} Gruppen",
|
||||
"whoTheyReach": "Wen sie erreichen können"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Mehrfachansicht",
|
||||
"cap2": "Voreinstellung",
|
||||
"cap1": "Einzeln",
|
||||
"stageExperimental": "Experimentell",
|
||||
"toggleList": "Liste ein-/ausblenden",
|
||||
"openViews": "Ansichtsliste öffnen",
|
||||
"expandList": "Liste ausklappen",
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Own-language access, by country internet use",
|
||||
"aria": "Charts",
|
||||
"bandCaveat": "Internet use is the country's figure (World Bank), never the group's.",
|
||||
"gapSub": "The 8 biggest of those shown, by population. Click one to open it.",
|
||||
"gapTake": "These {k} alone are {people} people.",
|
||||
"gapTitle": "The largest people groups with nothing in their own language",
|
||||
"groups": "Groups",
|
||||
"groupsN": "{n} groups",
|
||||
"measure": "Count",
|
||||
"messagingTop": "Top messaging app, by country in scope",
|
||||
"notMeasured": "Not measured",
|
||||
"ntSub": "Languages of the groups shown with no New Testament found, by how many people speak them. Red: nothing at all; yellow: something, but no New Testament.",
|
||||
"ntTake": "{n} languages here have no New Testament found, spoken by {people} people.",
|
||||
"ntTitle": "Which languages most need the New Testament?",
|
||||
"people": "People",
|
||||
"peopleN": "{n} people",
|
||||
"q1sub": "One bar per level of access, for the people groups shown.",
|
||||
"q1take": "{n} of {total} people groups shown have nothing found in their own language.",
|
||||
"q1title": "What do they have in their own language?",
|
||||
"q2sub": "People groups with nothing in their own language, by how much of their country is online.",
|
||||
"q2take": "{online} of the {n} with nothing live in countries where 60% or more use the internet.",
|
||||
"q2title": "Could the internet carry it to those with nothing?",
|
||||
"socialTop": "Top social platform, by country in scope",
|
||||
"unitCountries": "countries",
|
||||
"unitGroups": "people groups",
|
||||
|
|
@ -49,6 +61,18 @@
|
|||
"translatedBy": "Translated by",
|
||||
"wayIn": "Way in"
|
||||
},
|
||||
"combos": {
|
||||
"audioOnline": "Hear but cannot read, country online",
|
||||
"deaf": "A Deaf bridge exists",
|
||||
"digitalNow": "Nothing in their language, country 60%+ online",
|
||||
"indiaChinaGap": "India and China: nothing in their language",
|
||||
"none": "Everything — pick a filter or a combination",
|
||||
"offlineGap": "Nothing in their language, country mostly offline",
|
||||
"selection": "Your selection",
|
||||
"telegram": "Nothing in their language, Telegram country",
|
||||
"try": "Try a combination",
|
||||
"whatsapp": "Nothing in their language, WhatsApp country"
|
||||
},
|
||||
"detail": {
|
||||
"hear": "Watch or hear, cannot read",
|
||||
"none": "Nothing found",
|
||||
|
|
@ -107,6 +131,8 @@
|
|||
"count": "{n} of {total} people groups",
|
||||
"empty": "No people groups match these filters.",
|
||||
"exported": "{n} rows exported (copied and downloaded).",
|
||||
"openCapped": "Opens the first {n} selected.",
|
||||
"openInGospel": "Open in Gospel Resources",
|
||||
"selectRow": "Select {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
@ -186,6 +212,14 @@
|
|||
"languagesHere": "languages here",
|
||||
"legendTitle": "In their own language",
|
||||
"levels": "Family › branch › language › dialect. A family or branch shows the worst colour inside it. A dialect shows its language's colour, lighter: resources are catalogued per language, not per dialect. Click a circle to narrow the board to it.",
|
||||
"poster": "Poster",
|
||||
"poster3ft": "3 × 3 ft",
|
||||
"poster4ft": "4 × 4 ft",
|
||||
"posterOmitted": "{n} names left off where they would overlap another; every circle is drawn.",
|
||||
"posterPrint": "Print",
|
||||
"posterSizeAria": "Poster size",
|
||||
"posterSvg": "Download SVG",
|
||||
"posterTitle": "Gospel access, language family by language family",
|
||||
"sources": "Tree: Glottolog (CC BY 4.0). Resources: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, JESUS Film, Global Recordings and others, keyed by ISO 639-3.",
|
||||
"st": {
|
||||
"g": "New Testament or full Bible found",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
},
|
||||
"upg100WallHeading": "My UPG 100 List — Prayer Lists & Posters",
|
||||
"upg100WallTag": "Prayer Mobilization",
|
||||
"upg100WallIntro": "Hand away the prayer task in clusters of fifties and hundreds — the way Jesus fed the 5,000 (Luke 9:14). Each poster is a 100-list you can pray, print, and put on a wall — praying for them <strong>by their people group name</strong>.",
|
||||
"upg100WallIntroA": "Hand away the prayer task in clusters of fifties and hundreds — the way Jesus fed the 5,000 (Luke 9:14). Each poster is a 100-list you can pray, print, and put on a wall — praying for them ",
|
||||
"upg100WallIntroEmph": "by their people group name",
|
||||
"upg100WallIntroB": ".",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"sendMap": "Send Map",
|
||||
"flowerMesh": "Flower Mesh",
|
||||
"langCluster": "Language Clusters",
|
||||
"flowers": "Flowers"
|
||||
"flowers": "Flowers",
|
||||
"mobilize": "Mobilize",
|
||||
"campaigns": "Campaigns"
|
||||
},
|
||||
"hints": {
|
||||
"cockpit": "crossfilter hub",
|
||||
|
|
@ -172,5 +174,80 @@
|
|||
"sm": {
|
||||
"counts": "{u} UPGs · {s} reached senders",
|
||||
"noSenders": "No qualifying reached senders with a known country centroid for this target."
|
||||
},
|
||||
"about": {
|
||||
"button": "How it works",
|
||||
"title": "Mobilizing near neighbors",
|
||||
"close": "Close",
|
||||
"why": "The believers best placed to reach an unreached people are usually the ones nearest to it in language and culture. This tool finds them for every unreached group, using Ralph D. Winter's E-scale and P-scale, so you can plan whom to mobilize, in which language, and where.",
|
||||
"eTitle": "E-scale: the distance a witness must cross",
|
||||
"pTitle": "P-scale: the distance between a people and a church",
|
||||
"dataTitle": "Data and limits",
|
||||
"data": "Reached means at least 2% evangelical (the IMB definition). Language and culture are judged from the language code and the IMB people taxonomy (cluster, affinity bloc, language family): good public proxies, not field assessments. Believer numbers are estimates from population and evangelical bands; use them to size an audience, not as counts.",
|
||||
"source": "People group data: peoplegroups.org (IMB), people_groups.csv. E-scale and P-scale: Ralph D. Winter, Lausanne Congress, 1974."
|
||||
},
|
||||
"e": {
|
||||
"short1": "E-1 · same language",
|
||||
"short2": "E-2 · near culture",
|
||||
"short3": "E-3 · nearby, other culture",
|
||||
"title1": "E-1 · They speak the same language",
|
||||
"title2": "E-2 · A near culture",
|
||||
"title3": "E-3 · Nearby, a different culture",
|
||||
"long1": "Believers who speak this group's language. They can share the gospel without crossing a language barrier, and a campaign in this language can mobilize them.",
|
||||
"long2": "Believers from a related people: the same people cluster, or the same language family in the same affinity bloc. A shorter cultural step than starting from scratch.",
|
||||
"long3": "Believers living nearby (within 600 km or in the same country) whose culture is different. They share the region, not the language."
|
||||
},
|
||||
"p": {
|
||||
"title0": "No reached neighbors found",
|
||||
"title1": "Believers in its own language exist",
|
||||
"title2": "The nearest believers are a near culture",
|
||||
"title3": "The nearest believers are a distant culture",
|
||||
"long0": "No reached group matched this people by language, culture or distance in this data.",
|
||||
"long1": "Somewhere, a reached group speaks this people's language. Mobilizing them is the most direct path.",
|
||||
"long2": "No reached group speaks this language, but related peoples have believers.",
|
||||
"long3": "Only believers of a different culture are nearby. Reaching this people needs cross-cultural workers."
|
||||
},
|
||||
"res": {
|
||||
"bible": "Bible",
|
||||
"film": "JESUS Film",
|
||||
"audio": "Audio",
|
||||
"radio": "Radio"
|
||||
},
|
||||
"mob": {
|
||||
"legend": "Map legend",
|
||||
"legendUnreached": "Unreached group",
|
||||
"searchPlaceholder": "Search an unreached group, country or language",
|
||||
"pFilter": "Filter by P-level",
|
||||
"pAll": "All",
|
||||
"loading": "Loading people groups…",
|
||||
"intro": "Pick an unreached group (largest first), or click a red dot on the map, to see whom to mobilize.",
|
||||
"noMatch": "Nothing matches.",
|
||||
"back": "All groups",
|
||||
"people": "{n} people",
|
||||
"believersNear": "About {n} believers among the neighbors below.",
|
||||
"resources": "Resources in its language",
|
||||
"export": "Export audience segments (CSV)",
|
||||
"openBridge": "Plan a {lang} campaign",
|
||||
"samePeople": "same people",
|
||||
"cardStats": "~{b} believers · {km} km away",
|
||||
"noNeighbors": "No reached neighbors in this data.",
|
||||
"caveat": "Estimates from peoplegroups.org data. Check local realities before acting.",
|
||||
"loadError": "Could not load the people-group data: {message}"
|
||||
},
|
||||
"camp": {
|
||||
"intro": "Bridge languages: languages spoken by believers and by unreached peoples. One campaign in the language, aimed where the believers live, can mobilize them to reach the unreached in their own language.",
|
||||
"searchPlaceholder": "Search a language or country",
|
||||
"minBelievers": "At least",
|
||||
"exportAll": "Export list (CSV)",
|
||||
"unreachedPeople": "{n} unreached",
|
||||
"believers": "~{n} believers",
|
||||
"believersIn": "Believers in: {list}",
|
||||
"back": "All languages",
|
||||
"bigBelievers": "believers to mobilize ({g} groups, {c} countries)",
|
||||
"bigUnreached": "unreached people to reach ({g} groups, {c} countries)",
|
||||
"export": "Export this campaign (CSV)",
|
||||
"whereToRun": "Where to run it: believers by country",
|
||||
"believersInGroups": "~{n} in {g} groups",
|
||||
"whoTheyReach": "Whom they can reach"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Multi-view",
|
||||
"cap2": "Preset",
|
||||
"cap1": "Single",
|
||||
"stageExperimental": "Experimental",
|
||||
"toggleList": "Toggle list",
|
||||
"openViews": "Open the list of views",
|
||||
"expandList": "Expand list",
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Acceso en su idioma, según el uso de internet del país",
|
||||
"aria": "Gráficos",
|
||||
"bandCaveat": "El uso de internet es la cifra del país (Banco Mundial), nunca la del grupo.",
|
||||
"gapSub": "Los 8 más grandes de los mostrados, por población. Haga clic en uno para abrirlo.",
|
||||
"gapTake": "Solo estos {k} son {people} personas.",
|
||||
"gapTitle": "Los grupos étnicos más grandes sin nada en su propio idioma",
|
||||
"groups": "Grupos",
|
||||
"groupsN": "{n} grupos",
|
||||
"measure": "Contar",
|
||||
"messagingTop": "App de mensajería principal, por país en la selección",
|
||||
"notMeasured": "No medido",
|
||||
"ntSub": "Idiomas de los grupos mostrados sin Nuevo Testamento encontrado, según cuántas personas los hablan. Rojo: nada; amarillo: algo, pero sin Nuevo Testamento.",
|
||||
"ntTake": "{n} idiomas aquí no tienen Nuevo Testamento encontrado, hablados por {people} personas.",
|
||||
"ntTitle": "¿Qué idiomas necesitan más el Nuevo Testamento?",
|
||||
"people": "Personas",
|
||||
"peopleN": "{n} personas",
|
||||
"q1sub": "Una barra por nivel de acceso, para los grupos étnicos mostrados.",
|
||||
"q1take": "{n} de {total} grupos étnicos mostrados no tienen nada en su propio idioma.",
|
||||
"q1title": "¿Qué tienen en su propio idioma?",
|
||||
"q2sub": "Grupos étnicos sin nada en su idioma, según cuánto de su país está en línea.",
|
||||
"q2take": "{online} de los {n} sin nada viven en países donde el 60 % o más usa internet.",
|
||||
"q2title": "¿Podría internet llevarlo a quienes no tienen nada?",
|
||||
"socialTop": "Red social principal, por país en la selección",
|
||||
"unitCountries": "países",
|
||||
"unitGroups": "grupos étnicos",
|
||||
|
|
@ -49,6 +61,18 @@
|
|||
"translatedBy": "Traducido por",
|
||||
"wayIn": "Vía de entrada"
|
||||
},
|
||||
"combos": {
|
||||
"audioOnline": "Oyen pero no leen, país en línea",
|
||||
"deaf": "Existe un puente en lengua de señas",
|
||||
"digitalNow": "Nada en su idioma, país 60 %+ en línea",
|
||||
"indiaChinaGap": "India y China: nada en su idioma",
|
||||
"none": "Todo: elija un filtro o una combinación",
|
||||
"offlineGap": "Nada en su idioma, país mayormente sin conexión",
|
||||
"selection": "Su selección",
|
||||
"telegram": "Nada en su idioma, país de Telegram",
|
||||
"try": "Probar una combinación",
|
||||
"whatsapp": "Nada en su idioma, país de WhatsApp"
|
||||
},
|
||||
"detail": {
|
||||
"hear": "Ver u oír, no leer",
|
||||
"none": "Nada encontrado",
|
||||
|
|
@ -107,6 +131,8 @@
|
|||
"count": "{n} de {total} grupos étnicos",
|
||||
"empty": "Ningún grupo étnico coincide con estos filtros.",
|
||||
"exported": "{n} filas exportadas (copiadas y descargadas).",
|
||||
"openCapped": "Abre los primeros {n} seleccionados.",
|
||||
"openInGospel": "Abrir en Recursos del Evangelio",
|
||||
"selectRow": "Seleccionar {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
@ -186,6 +212,14 @@
|
|||
"languagesHere": "idiomas aquí",
|
||||
"legendTitle": "En su propio idioma",
|
||||
"levels": "Familia › rama › idioma › dialecto. Una familia o rama muestra el peor color que contiene. Un dialecto muestra el color de su idioma, más claro: los recursos se catalogan por idioma, no por dialecto. Haga clic en un círculo para limitar el tablero a él.",
|
||||
"poster": "Póster",
|
||||
"poster3ft": "3 × 3 pies",
|
||||
"poster4ft": "4 × 4 pies",
|
||||
"posterOmitted": "{n} nombres omitidos donde se superpondrían a otro; cada círculo está dibujado.",
|
||||
"posterPrint": "Imprimir",
|
||||
"posterSizeAria": "Tamaño del póster",
|
||||
"posterSvg": "Descargar SVG",
|
||||
"posterTitle": "Acceso al evangelio, familia lingüística por familia lingüística",
|
||||
"sources": "Árbol: Glottolog (CC BY 4.0). Recursos: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, película JESÚS, Global Recordings y otros, por ISO 639-3.",
|
||||
"st": {
|
||||
"g": "Nuevo Testamento o Biblia completa encontrados",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
},
|
||||
"upg100WallHeading": "Mi lista UPG 100 — Listas de oración y carteles",
|
||||
"upg100WallTag": "Movilización de oración",
|
||||
"upg100WallIntro": "Reparte la tarea de la oración en grupos de cincuenta y de cien, como Jesús alimentó a los 5.000 (Lucas 9:14). Cada cartel es una lista de 100 que puedes orar, imprimir y poner en la pared — orando por ellos <strong>por el nombre de su pueblo</strong>.",
|
||||
"upg100WallIntroA": "Reparte la tarea de la oración en grupos de cincuenta y de cien, como Jesús alimentó a los 5.000 (Lucas 9:14). Cada cartel es una lista de 100 que puedes orar, imprimir y poner en la pared — orando por ellos ",
|
||||
"upg100WallIntroEmph": "por el nombre de su pueblo",
|
||||
"upg100WallIntroB": ".",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"sendMap": "Mapa de envío",
|
||||
"flowerMesh": "Malla floral",
|
||||
"langCluster": "Grupos lingüísticos",
|
||||
"flowers": "Flores"
|
||||
"flowers": "Flores",
|
||||
"mobilize": "Movilizar",
|
||||
"campaigns": "Campañas"
|
||||
},
|
||||
"hints": {
|
||||
"cockpit": "centro de filtros cruzados",
|
||||
|
|
@ -172,5 +174,80 @@
|
|||
"sm": {
|
||||
"counts": "{u} PNA · {s} enviadores alcanzados",
|
||||
"noSenders": "No hay enviadores alcanzados que califiquen con un centroide de país conocido para este objetivo."
|
||||
},
|
||||
"about": {
|
||||
"button": "Cómo funciona",
|
||||
"title": "Movilizar a los vecinos cercanos",
|
||||
"close": "Cerrar",
|
||||
"why": "Los creyentes mejor situados para alcanzar a un pueblo no alcanzado suelen ser los más cercanos a él en idioma y cultura. Esta herramienta los encuentra para cada grupo no alcanzado, con la escala E y la escala P de Ralph D. Winter, para que puedas planificar a quién movilizar, en qué idioma y dónde.",
|
||||
"eTitle": "Escala E: la distancia que debe cruzar un testigo",
|
||||
"pTitle": "Escala P: la distancia entre un pueblo y una iglesia",
|
||||
"dataTitle": "Datos y límites",
|
||||
"data": "Alcanzado significa al menos un 2 % de evangélicos (definición del IMB). El idioma y la cultura se deducen del código de idioma y de la taxonomía de pueblos del IMB (clúster, bloque de afinidad, familia lingüística): buenas aproximaciones públicas, no evaluaciones de campo. El número de creyentes se estima a partir de la población y los rangos de evangélicos; sirve para dimensionar una audiencia, no como recuento.",
|
||||
"source": "Datos de pueblos: peoplegroups.org (IMB), people_groups.csv. Escala E y escala P: Ralph D. Winter, Congreso de Lausana, 1974."
|
||||
},
|
||||
"e": {
|
||||
"short1": "E-1 · mismo idioma",
|
||||
"short2": "E-2 · cultura cercana",
|
||||
"short3": "E-3 · cerca, otra cultura",
|
||||
"title1": "E-1 · Hablan el mismo idioma",
|
||||
"title2": "E-2 · Una cultura cercana",
|
||||
"title3": "E-3 · Cerca, otra cultura",
|
||||
"long1": "Creyentes que hablan el idioma de este grupo. Pueden compartir el evangelio sin cruzar una barrera lingüística, y una campaña en este idioma puede movilizarlos.",
|
||||
"long2": "Creyentes de un pueblo emparentado: el mismo clúster de pueblos, o la misma familia lingüística en el mismo bloque de afinidad. Un paso cultural más corto que empezar de cero.",
|
||||
"long3": "Creyentes que viven cerca (a menos de 600 km o en el mismo país) con una cultura distinta. Comparten la región, no el idioma."
|
||||
},
|
||||
"p": {
|
||||
"title0": "No se encontraron vecinos alcanzados",
|
||||
"title1": "Existen creyentes en su idioma",
|
||||
"title2": "Los creyentes más cercanos son de una cultura cercana",
|
||||
"title3": "Los creyentes más cercanos son de una cultura lejana",
|
||||
"long0": "En estos datos ningún grupo alcanzado coincide con este pueblo por idioma, cultura o distancia.",
|
||||
"long1": "En algún lugar, un grupo alcanzado habla el idioma de este pueblo. Movilizarlo es el camino más directo.",
|
||||
"long2": "Ningún grupo alcanzado habla este idioma, pero pueblos emparentados tienen creyentes.",
|
||||
"long3": "Cerca solo hay creyentes de otra cultura. Alcanzar a este pueblo requiere obreros transculturales."
|
||||
},
|
||||
"res": {
|
||||
"bible": "Biblia",
|
||||
"film": "Película JESÚS",
|
||||
"audio": "Audio",
|
||||
"radio": "Radio"
|
||||
},
|
||||
"mob": {
|
||||
"legend": "Leyenda del mapa",
|
||||
"legendUnreached": "Grupo no alcanzado",
|
||||
"searchPlaceholder": "Busca un grupo no alcanzado, país o idioma",
|
||||
"pFilter": "Filtrar por nivel P",
|
||||
"pAll": "Todos",
|
||||
"loading": "Cargando pueblos…",
|
||||
"intro": "Elige un grupo no alcanzado (los más grandes primero) o haz clic en un punto rojo del mapa para ver a quién movilizar.",
|
||||
"noMatch": "Sin resultados.",
|
||||
"back": "Todos los grupos",
|
||||
"people": "{n} personas",
|
||||
"believersNear": "Unos {n} creyentes entre los vecinos de abajo.",
|
||||
"resources": "Recursos en su idioma",
|
||||
"export": "Exportar segmentos de audiencia (CSV)",
|
||||
"openBridge": "Planificar una campaña en {lang}",
|
||||
"samePeople": "mismo pueblo",
|
||||
"cardStats": "~{b} creyentes · a {km} km",
|
||||
"noNeighbors": "No hay vecinos alcanzados en estos datos.",
|
||||
"caveat": "Estimaciones a partir de datos de peoplegroups.org. Comprueba la realidad local antes de actuar.",
|
||||
"loadError": "No se pudieron cargar los datos de pueblos: {message}"
|
||||
},
|
||||
"camp": {
|
||||
"intro": "Idiomas puente: idiomas que hablan creyentes y pueblos no alcanzados. Una campaña en ese idioma, dirigida a donde viven los creyentes, puede movilizarlos para alcanzar a los no alcanzados en su propio idioma.",
|
||||
"searchPlaceholder": "Busca un idioma o país",
|
||||
"minBelievers": "Al menos",
|
||||
"exportAll": "Exportar lista (CSV)",
|
||||
"unreachedPeople": "{n} no alcanzados",
|
||||
"believers": "~{n} creyentes",
|
||||
"believersIn": "Creyentes en: {list}",
|
||||
"back": "Todos los idiomas",
|
||||
"bigBelievers": "creyentes que movilizar ({g} grupos, {c} países)",
|
||||
"bigUnreached": "no alcanzados a los que llegar ({g} grupos, {c} países)",
|
||||
"export": "Exportar esta campaña (CSV)",
|
||||
"whereToRun": "Dónde lanzarla: creyentes por país",
|
||||
"believersInGroups": "~{n} en {g} grupos",
|
||||
"whoTheyReach": "A quién pueden alcanzar"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Multivista",
|
||||
"cap2": "Preajuste",
|
||||
"cap1": "Individual",
|
||||
"stageExperimental": "Experimental",
|
||||
"toggleList": "Mostrar u ocultar la lista",
|
||||
"openViews": "Abrir la lista de vistas",
|
||||
"expandList": "Expandir la lista",
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Accès dans leur langue, selon l’usage d’internet du pays",
|
||||
"aria": "Graphiques",
|
||||
"bandCaveat": "L’usage d’internet est le chiffre du pays (Banque mondiale), jamais celui du groupe.",
|
||||
"gapSub": "Les 8 plus grands de ceux affichés, par population. Cliquez pour en ouvrir un.",
|
||||
"gapTake": "Ces {k} seuls représentent {people} personnes.",
|
||||
"gapTitle": "Les plus grands groupes ethniques sans rien dans leur propre langue",
|
||||
"groups": "Groupes",
|
||||
"groupsN": "{n} groupes",
|
||||
"measure": "Compter",
|
||||
"messagingTop": "Messagerie principale, par pays sélectionné",
|
||||
"notMeasured": "Non mesuré",
|
||||
"ntSub": "Langues des groupes affichés sans Nouveau Testament trouvé, selon le nombre de locuteurs. Rouge : rien du tout ; jaune : quelque chose, mais pas de Nouveau Testament.",
|
||||
"ntTake": "{n} langues ici n’ont pas de Nouveau Testament trouvé, parlées par {people} personnes.",
|
||||
"ntTitle": "Quelles langues ont le plus besoin du Nouveau Testament ?",
|
||||
"people": "Personnes",
|
||||
"peopleN": "{n} personnes",
|
||||
"q1sub": "Une barre par niveau d’accès, pour les groupes ethniques affichés.",
|
||||
"q1take": "{n} des {total} groupes ethniques affichés n’ont rien dans leur propre langue.",
|
||||
"q1title": "Qu’ont-ils dans leur propre langue ?",
|
||||
"q2sub": "Groupes ethniques sans rien dans leur langue, selon la part de leur pays en ligne.",
|
||||
"q2take": "{online} des {n} sans rien vivent dans des pays où 60 % ou plus utilisent internet.",
|
||||
"q2title": "Internet pourrait-il le porter à ceux qui n’ont rien ?",
|
||||
"socialTop": "Réseau social principal, par pays sélectionné",
|
||||
"unitCountries": "pays",
|
||||
"unitGroups": "groupes ethniques",
|
||||
|
|
@ -49,6 +61,18 @@
|
|||
"translatedBy": "Traduit par",
|
||||
"wayIn": "Porte d’entrée"
|
||||
},
|
||||
"combos": {
|
||||
"audioOnline": "Entendent sans lire, pays en ligne",
|
||||
"deaf": "Une passerelle en langue des signes existe",
|
||||
"digitalNow": "Rien dans leur langue, pays en ligne à 60 %+",
|
||||
"indiaChinaGap": "Inde et Chine : rien dans leur langue",
|
||||
"none": "Tout — choisissez un filtre ou une combinaison",
|
||||
"offlineGap": "Rien dans leur langue, pays surtout hors ligne",
|
||||
"selection": "Votre sélection",
|
||||
"telegram": "Rien dans leur langue, pays Telegram",
|
||||
"try": "Essayer une combinaison",
|
||||
"whatsapp": "Rien dans leur langue, pays WhatsApp"
|
||||
},
|
||||
"detail": {
|
||||
"hear": "Voir ou entendre, sans lire",
|
||||
"none": "Rien trouvé",
|
||||
|
|
@ -107,6 +131,8 @@
|
|||
"count": "{n} sur {total} groupes ethniques",
|
||||
"empty": "Aucun groupe ethnique ne correspond à ces filtres.",
|
||||
"exported": "{n} lignes exportées (copiées et téléchargées).",
|
||||
"openCapped": "Ouvre les {n} premiers sélectionnés.",
|
||||
"openInGospel": "Ouvrir dans Ressources de l’Évangile",
|
||||
"selectRow": "Sélectionner {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
@ -186,6 +212,14 @@
|
|||
"languagesHere": "langues ici",
|
||||
"legendTitle": "Dans leur propre langue",
|
||||
"levels": "Famille › branche › langue › dialecte. Une famille ou une branche prend la pire couleur qu’elle contient. Un dialecte prend la couleur de sa langue, plus claire : les ressources sont recensées par langue, pas par dialecte. Cliquez sur un cercle pour y restreindre le tableau.",
|
||||
"poster": "Affiche",
|
||||
"poster3ft": "3 × 3 pi",
|
||||
"poster4ft": "4 × 4 pi",
|
||||
"posterOmitted": "{n} noms omis là où ils en chevaucheraient un autre ; chaque cercle est dessiné.",
|
||||
"posterPrint": "Imprimer",
|
||||
"posterSizeAria": "Taille de l’affiche",
|
||||
"posterSvg": "Télécharger le SVG",
|
||||
"posterTitle": "L’accès à l’Évangile, famille de langues par famille de langues",
|
||||
"sources": "Arbre : Glottolog (CC BY 4.0). Ressources : eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, film JÉSUS, Global Recordings et d’autres, par code ISO 639-3.",
|
||||
"st": {
|
||||
"g": "Nouveau Testament ou Bible complète trouvé",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
},
|
||||
"upg100WallHeading": "Ma liste UPG 100 — Listes de prière et affiches",
|
||||
"upg100WallTag": "Mobilisation pour la prière",
|
||||
"upg100WallIntro": "Répartissez la tâche de la prière par groupes de cinquante et de cent — comme Jésus a nourri les 5 000 (Luc 9:14). Chaque affiche est une liste de 100 que vous pouvez prier, imprimer et afficher au mur — en priant pour eux <strong>par le nom de leur peuple</strong>.",
|
||||
"upg100WallIntroA": "Répartissez la tâche de la prière par groupes de cinquante et de cent — comme Jésus a nourri les 5 000 (Luc 9:14). Chaque affiche est une liste de 100 que vous pouvez prier, imprimer et afficher au mur — en priant pour eux ",
|
||||
"upg100WallIntroEmph": "par le nom de leur peuple",
|
||||
"upg100WallIntroB": ".",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"sendMap": "Carte d’envoi",
|
||||
"flowerMesh": "Maillage floral",
|
||||
"langCluster": "Groupes linguistiques",
|
||||
"flowers": "Fleurs"
|
||||
"flowers": "Fleurs",
|
||||
"mobilize": "Mobiliser",
|
||||
"campaigns": "Campagnes"
|
||||
},
|
||||
"hints": {
|
||||
"cockpit": "centre de filtrage croisé",
|
||||
|
|
@ -172,5 +174,80 @@
|
|||
"sm": {
|
||||
"counts": "{u} PNA · {s} envoyeurs atteints",
|
||||
"noSenders": "Aucun envoyeur atteint admissible avec un centroïde de pays connu pour cette cible."
|
||||
},
|
||||
"about": {
|
||||
"button": "Comment ça marche",
|
||||
"title": "Mobiliser les proches voisins",
|
||||
"close": "Fermer",
|
||||
"why": "Les croyants les mieux placés pour atteindre un peuple non atteint sont généralement les plus proches de lui par la langue et la culture. Cet outil les trouve pour chaque groupe non atteint, à l'aide de l'échelle E et de l'échelle P de Ralph D. Winter, pour planifier qui mobiliser, dans quelle langue et où.",
|
||||
"eTitle": "Échelle E : la distance qu'un témoin doit franchir",
|
||||
"pTitle": "Échelle P : la distance entre un peuple et une Église",
|
||||
"dataTitle": "Données et limites",
|
||||
"data": "Atteint signifie au moins 2 % d'évangéliques (définition de l'IMB). La langue et la culture sont déduites du code de langue et de la taxonomie des peuples de l'IMB (grappe, bloc d'affinité, famille de langues) : de bons indicateurs publics, pas des évaluations de terrain. Le nombre de croyants est estimé à partir de la population et des tranches d'évangéliques ; il sert à dimensionner une audience, pas à compter.",
|
||||
"source": "Données sur les peuples : peoplegroups.org (IMB), people_groups.csv. Échelles E et P : Ralph D. Winter, Congrès de Lausanne, 1974."
|
||||
},
|
||||
"e": {
|
||||
"short1": "E-1 · même langue",
|
||||
"short2": "E-2 · culture proche",
|
||||
"short3": "E-3 · proche, autre culture",
|
||||
"title1": "E-1 · Ils parlent la même langue",
|
||||
"title2": "E-2 · Une culture proche",
|
||||
"title3": "E-3 · Proches, une autre culture",
|
||||
"long1": "Des croyants qui parlent la langue de ce groupe. Ils peuvent partager l'Évangile sans barrière de langue, et une campagne dans cette langue peut les mobiliser.",
|
||||
"long2": "Des croyants d'un peuple apparenté : même grappe de peuples, ou même famille de langues dans le même bloc d'affinité. Un pas culturel plus court que de partir de zéro.",
|
||||
"long3": "Des croyants vivant à proximité (à moins de 600 km ou dans le même pays) mais d'une autre culture. Ils partagent la région, pas la langue."
|
||||
},
|
||||
"p": {
|
||||
"title0": "Aucun voisin atteint trouvé",
|
||||
"title1": "Il existe des croyants dans sa langue",
|
||||
"title2": "Les croyants les plus proches sont une culture proche",
|
||||
"title3": "Les croyants les plus proches sont une culture lointaine",
|
||||
"long0": "Dans ces données, aucun groupe atteint ne correspond à ce peuple par la langue, la culture ou la distance.",
|
||||
"long1": "Quelque part, un groupe atteint parle la langue de ce peuple. Le mobiliser est la voie la plus directe.",
|
||||
"long2": "Aucun groupe atteint ne parle cette langue, mais des peuples apparentés ont des croyants.",
|
||||
"long3": "À proximité, il n'y a que des croyants d'une autre culture. Atteindre ce peuple demande des ouvriers interculturels."
|
||||
},
|
||||
"res": {
|
||||
"bible": "Bible",
|
||||
"film": "Film JÉSUS",
|
||||
"audio": "Audio",
|
||||
"radio": "Radio"
|
||||
},
|
||||
"mob": {
|
||||
"legend": "Légende de la carte",
|
||||
"legendUnreached": "Groupe non atteint",
|
||||
"searchPlaceholder": "Rechercher un groupe non atteint, un pays ou une langue",
|
||||
"pFilter": "Filtrer par niveau P",
|
||||
"pAll": "Tous",
|
||||
"loading": "Chargement des peuples…",
|
||||
"intro": "Choisissez un groupe non atteint (les plus grands d’abord) ou cliquez sur un point rouge pour voir qui mobiliser.",
|
||||
"noMatch": "Aucun résultat.",
|
||||
"back": "Tous les groupes",
|
||||
"people": "{n} personnes",
|
||||
"believersNear": "Environ {n} croyants parmi les voisins ci-dessous.",
|
||||
"resources": "Ressources dans sa langue",
|
||||
"export": "Exporter les segments d'audience (CSV)",
|
||||
"openBridge": "Planifier une campagne en {lang}",
|
||||
"samePeople": "même peuple",
|
||||
"cardStats": "~{b} croyants · à {km} km",
|
||||
"noNeighbors": "Aucun voisin atteint dans ces données.",
|
||||
"caveat": "Estimations tirées des données de peoplegroups.org. Vérifiez les réalités locales avant d'agir.",
|
||||
"loadError": "Impossible de charger les données des peuples : {message}"
|
||||
},
|
||||
"camp": {
|
||||
"intro": "Langues-ponts : des langues parlées par des croyants et par des peuples non atteints. Une campagne dans cette langue, diffusée là où vivent les croyants, peut les mobiliser pour atteindre les non-atteints dans leur propre langue.",
|
||||
"searchPlaceholder": "Rechercher une langue ou un pays",
|
||||
"minBelievers": "Au moins",
|
||||
"exportAll": "Exporter la liste (CSV)",
|
||||
"unreachedPeople": "{n} non atteints",
|
||||
"believers": "~{n} croyants",
|
||||
"believersIn": "Croyants en : {list}",
|
||||
"back": "Toutes les langues",
|
||||
"bigBelievers": "croyants à mobiliser ({g} groupes, {c} pays)",
|
||||
"bigUnreached": "non-atteints à atteindre ({g} groupes, {c} pays)",
|
||||
"export": "Exporter cette campagne (CSV)",
|
||||
"whereToRun": "Où la diffuser : croyants par pays",
|
||||
"believersInGroups": "~{n} dans {g} groupes",
|
||||
"whoTheyReach": "Qui ils peuvent atteindre"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Multivue",
|
||||
"cap2": "Préréglage",
|
||||
"cap1": "Simple",
|
||||
"stageExperimental": "Expérimental",
|
||||
"toggleList": "Afficher ou masquer la liste",
|
||||
"openViews": "Ouvrir la liste des vues",
|
||||
"expandList": "Déplier la liste",
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "अपनी भाषा में पहुँच, देश के इंटरनेट उपयोग के अनुसार",
|
||||
"aria": "चार्ट",
|
||||
"bandCaveat": "इंटरनेट उपयोग देश का आंकड़ा है (विश्व बैंक), समूह का कभी नहीं।",
|
||||
"gapSub": "दिखाए गए में से जनसंख्या के अनुसार 8 सबसे बड़े। खोलने के लिए किसी पर क्लिक करें।",
|
||||
"gapTake": "केवल ये {k} ही {people} लोग हैं।",
|
||||
"gapTitle": "अपनी भाषा में कुछ भी न रखने वाले सबसे बड़े जन-समूह",
|
||||
"groups": "समूह",
|
||||
"groupsN": "{n} समूह",
|
||||
"measure": "गिनती",
|
||||
"messagingTop": "प्रमुख मैसेजिंग ऐप, दायरे के देशों के अनुसार",
|
||||
"notMeasured": "मापा नहीं गया",
|
||||
"ntSub": "दिखाए गए समूहों की वे भाषाएँ जिनमें नया नियम नहीं मिला, बोलने वालों की संख्या के अनुसार। लाल: कुछ नहीं; पीला: कुछ है, पर नया नियम नहीं।",
|
||||
"ntTake": "यहाँ {n} भाषाओं में नया नियम नहीं मिला, जिन्हें {people} लोग बोलते हैं।",
|
||||
"ntTitle": "किन भाषाओं को नए नियम की सबसे अधिक ज़रूरत है?",
|
||||
"people": "लोग",
|
||||
"peopleN": "{n} लोग",
|
||||
"q1sub": "दिखाए गए जन-समूहों के लिए, पहुँच के हर स्तर पर एक पट्टी।",
|
||||
"q1take": "दिखाए गए {total} जन-समूहों में से {n} की अपनी भाषा में कुछ नहीं मिला।",
|
||||
"q1title": "उनकी अपनी भाषा में क्या है?",
|
||||
"q2sub": "जिन जन-समूहों की भाषा में कुछ नहीं, उनके देश का कितना हिस्सा ऑनलाइन है उसके अनुसार।",
|
||||
"q2take": "जिन {n} के पास कुछ नहीं, उनमें से {online} ऐसे देशों में रहते हैं जहाँ 60% या अधिक लोग इंटरनेट का उपयोग करते हैं।",
|
||||
"q2title": "क्या इंटरनेट इसे उन तक पहुँचा सकता है जिनके पास कुछ नहीं है?",
|
||||
"socialTop": "प्रमुख सोशल प्लेटफ़ॉर्म, दायरे के देशों के अनुसार",
|
||||
"unitCountries": "देश",
|
||||
"unitGroups": "जन-समूह",
|
||||
|
|
@ -49,6 +61,18 @@
|
|||
"translatedBy": "अनुवादक",
|
||||
"wayIn": "रास्ता"
|
||||
},
|
||||
"combos": {
|
||||
"audioOnline": "सुन सकते हैं पर पढ़ नहीं सकते, देश ऑनलाइन",
|
||||
"deaf": "सांकेतिक भाषा सेतु मौजूद है",
|
||||
"digitalNow": "अपनी भाषा में कुछ नहीं, देश 60%+ ऑनलाइन",
|
||||
"indiaChinaGap": "भारत और चीन: अपनी भाषा में कुछ नहीं",
|
||||
"none": "सब कुछ — कोई फ़िल्टर या संयोजन चुनें",
|
||||
"offlineGap": "अपनी भाषा में कुछ नहीं, देश अधिकतर ऑफ़लाइन",
|
||||
"selection": "आपका चयन",
|
||||
"telegram": "अपनी भाषा में कुछ नहीं, Telegram देश",
|
||||
"try": "कोई संयोजन आज़माएँ",
|
||||
"whatsapp": "अपनी भाषा में कुछ नहीं, WhatsApp देश"
|
||||
},
|
||||
"detail": {
|
||||
"hear": "देख या सुन सकते हैं, पढ़ नहीं सकते",
|
||||
"none": "कुछ नहीं मिला",
|
||||
|
|
@ -107,6 +131,8 @@
|
|||
"count": "{total} में से {n} जन-समूह",
|
||||
"empty": "इन फ़िल्टरों से कोई जन-समूह मेल नहीं खाता।",
|
||||
"exported": "{n} पंक्तियाँ निर्यात हुईं (कॉपी और डाउनलोड)।",
|
||||
"openCapped": "पहले {n} चयनित खोलता है।",
|
||||
"openInGospel": "सुसमाचार संसाधनों में खोलें",
|
||||
"selectRow": "{name} चुनें"
|
||||
},
|
||||
"headline": {
|
||||
|
|
@ -186,6 +212,14 @@
|
|||
"languagesHere": "भाषाएँ यहाँ",
|
||||
"legendTitle": "उनकी अपनी भाषा में",
|
||||
"levels": "परिवार › शाखा › भाषा › बोली। परिवार या शाखा अपने भीतर का सबसे खराब रंग दिखाती है। बोली अपनी भाषा का रंग हल्का दिखाती है: संसाधन भाषा के अनुसार सूचीबद्ध होते हैं, बोली के अनुसार नहीं। बोर्ड को उस तक सीमित करने के लिए किसी वृत्त पर क्लिक करें।",
|
||||
"poster": "पोस्टर",
|
||||
"poster3ft": "3 × 3 फ़ुट",
|
||||
"poster4ft": "4 × 4 फ़ुट",
|
||||
"posterOmitted": "{n} नाम छोड़े गए जहाँ वे किसी दूसरे पर चढ़ जाते; हर वृत्त बनाया गया है।",
|
||||
"posterPrint": "प्रिंट करें",
|
||||
"posterSizeAria": "पोस्टर का आकार",
|
||||
"posterSvg": "SVG डाउनलोड करें",
|
||||
"posterTitle": "सुसमाचार तक पहुँच, एक-एक भाषा परिवार के अनुसार",
|
||||
"sources": "वृक्ष: Glottolog (CC BY 4.0)। संसाधन: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, यीशु फ़िल्म, Global Recordings और अन्य, ISO 639-3 के अनुसार।",
|
||||
"st": {
|
||||
"g": "नया नियम या पूरी बाइबल मिली",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
},
|
||||
"upg100WallHeading": "मेरी UPG 100 सूची — प्रार्थना सूचियाँ और पोस्टर",
|
||||
"upg100WallTag": "प्रार्थना लामबंदी",
|
||||
"upg100WallIntro": "प्रार्थना के काम को पचास-पचास और सौ-सौ के समूहों में बाँटें — जैसे यीशु ने 5,000 लोगों को खिलाया (लूका 9:14)। हर पोस्टर 100 की एक सूची है जिसके लिए आप प्रार्थना कर सकते हैं, छाप सकते हैं और दीवार पर लगा सकते हैं — उनके लिए <strong>उनके जनसमूह के नाम से</strong> प्रार्थना करते हुए।",
|
||||
"upg100WallIntroA": "प्रार्थना के काम को पचास-पचास और सौ-सौ के समूहों में बाँटें — जैसे यीशु ने 5,000 लोगों को खिलाया (लूका 9:14)। हर पोस्टर 100 की एक सूची है जिसके लिए आप प्रार्थना कर सकते हैं, छाप सकते हैं और दीवार पर लगा सकते हैं — उनके लिए ",
|
||||
"upg100WallIntroEmph": "उनके जनसमूह के नाम से",
|
||||
"upg100WallIntroB": " प्रार्थना करते हुए।",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"sendMap": "भेजने का नक्शा",
|
||||
"flowerMesh": "फूल-जाल",
|
||||
"langCluster": "भाषा समूह",
|
||||
"flowers": "फूल"
|
||||
"flowers": "फूल",
|
||||
"mobilize": "संगठित करें",
|
||||
"campaigns": "अभियान"
|
||||
},
|
||||
"hints": {
|
||||
"cockpit": "क्रॉस-फ़िल्टर केंद्र",
|
||||
|
|
@ -172,5 +174,80 @@
|
|||
"sm": {
|
||||
"counts": "{u} UPG · {s} पहुँचे हुए भेजने वाले",
|
||||
"noSenders": "इस लक्ष्य के लिए ज्ञात देश-केंद्र वाला कोई योग्य पहुँचा हुआ भेजने वाला नहीं है।"
|
||||
},
|
||||
"about": {
|
||||
"button": "यह कैसे काम करता है",
|
||||
"title": "निकट पड़ोसियों को संगठित करना",
|
||||
"close": "बंद करें",
|
||||
"why": "किसी अपहुँच जनसमूह तक पहुँचने के लिए सबसे उपयुक्त विश्वासी आम तौर पर वे होते हैं जो भाषा और संस्कृति में उसके सबसे निकट हैं। यह उपकरण राल्फ डी. विंटर के E-स्केल और P-स्केल से हर अपहुँच समूह के लिए उन्हें ढूँढता है, ताकि आप योजना बना सकें कि किसे, किस भाषा में और कहाँ संगठित करना है।",
|
||||
"eTitle": "E-स्केल: गवाह को कितनी दूरी पार करनी है",
|
||||
"pTitle": "P-स्केल: किसी जनसमूह और कलीसिया के बीच की दूरी",
|
||||
"dataTitle": "डेटा और सीमाएँ",
|
||||
"data": "पहुँचा हुआ का अर्थ है कम से कम 2% इवैंजेलिकल (IMB की परिभाषा)। भाषा और संस्कृति का अनुमान भाषा कोड और IMB के जनसमूह वर्गीकरण (क्लस्टर, सम्बद्धता खंड, भाषा परिवार) से लगाया गया है: अच्छे सार्वजनिक संकेतक, क्षेत्रीय आकलन नहीं। विश्वासियों की संख्या जनसंख्या और इवैंजेलिकल श्रेणियों से अनुमानित है; इसे दर्शक-संख्या के अनुमान के लिए लें, गिनती के रूप में नहीं।",
|
||||
"source": "जनसमूह डेटा: peoplegroups.org (IMB), people_groups.csv. E-स्केल और P-स्केल: राल्फ डी. विंटर, लॉज़ेन कांग्रेस, 1974."
|
||||
},
|
||||
"e": {
|
||||
"short1": "E-1 · वही भाषा",
|
||||
"short2": "E-2 · निकट संस्कृति",
|
||||
"short3": "E-3 · पास में, अन्य संस्कृति",
|
||||
"title1": "E-1 · वे वही भाषा बोलते हैं",
|
||||
"title2": "E-2 · एक निकट संस्कृति",
|
||||
"title3": "E-3 · पास में, भिन्न संस्कृति",
|
||||
"long1": "वे विश्वासी जो इस समूह की भाषा बोलते हैं। वे भाषा की बाधा पार किए बिना सुसमाचार बाँट सकते हैं, और इस भाषा में एक अभियान उन्हें संगठित कर सकता है।",
|
||||
"long2": "संबंधित जनसमूह के विश्वासी: वही जनसमूह क्लस्टर, या उसी सम्बद्धता खंड में वही भाषा परिवार। शून्य से शुरू करने की तुलना में छोटा सांस्कृतिक कदम।",
|
||||
"long3": "पास में रहने वाले विश्वासी (600 किमी के भीतर या उसी देश में) जिनकी संस्कृति भिन्न है। वे क्षेत्र साझा करते हैं, भाषा नहीं।"
|
||||
},
|
||||
"p": {
|
||||
"title0": "कोई पहुँचा हुआ पड़ोसी नहीं मिला",
|
||||
"title1": "इसकी अपनी भाषा में विश्वासी हैं",
|
||||
"title2": "निकटतम विश्वासी एक निकट संस्कृति के हैं",
|
||||
"title3": "निकटतम विश्वासी एक दूर की संस्कृति के हैं",
|
||||
"long0": "इस डेटा में कोई पहुँचा हुआ समूह भाषा, संस्कृति या दूरी से इस जनसमूह से मेल नहीं खाता।",
|
||||
"long1": "कहीं न कहीं कोई पहुँचा हुआ समूह इस जनसमूह की भाषा बोलता है। उन्हें संगठित करना सबसे सीधा रास्ता है।",
|
||||
"long2": "कोई पहुँचा हुआ समूह यह भाषा नहीं बोलता, पर संबंधित जनसमूहों में विश्वासी हैं।",
|
||||
"long3": "पास में केवल भिन्न संस्कृति के विश्वासी हैं। इस जनसमूह तक पहुँचने के लिए अंतर-सांस्कृतिक कार्यकर्ताओं की ज़रूरत है।"
|
||||
},
|
||||
"res": {
|
||||
"bible": "बाइबल",
|
||||
"film": "यीशु फ़िल्म",
|
||||
"audio": "ऑडियो",
|
||||
"radio": "रेडियो"
|
||||
},
|
||||
"mob": {
|
||||
"legend": "मानचित्र सूचकांक",
|
||||
"legendUnreached": "अपहुँच समूह",
|
||||
"searchPlaceholder": "अपहुँच समूह, देश या भाषा खोजें",
|
||||
"pFilter": "P-स्तर से छाँटें",
|
||||
"pAll": "सभी",
|
||||
"loading": "जनसमूह लोड हो रहे हैं…",
|
||||
"intro": "देखें किसे संगठित करना है: कोई अपहुँच समूह चुनें (सबसे बड़े पहले) या मानचित्र पर किसी लाल बिंदु पर क्लिक करें।",
|
||||
"noMatch": "कोई परिणाम नहीं।",
|
||||
"back": "सभी समूह",
|
||||
"people": "{n} लोग",
|
||||
"believersNear": "नीचे दिए पड़ोसियों में लगभग {n} विश्वासी।",
|
||||
"resources": "इसकी भाषा में संसाधन",
|
||||
"export": "दर्शक खंड निर्यात करें (CSV)",
|
||||
"openBridge": "{lang} में अभियान की योजना बनाएँ",
|
||||
"samePeople": "वही जनसमूह",
|
||||
"cardStats": "~{b} विश्वासी · {km} किमी दूर",
|
||||
"noNeighbors": "इस डेटा में कोई पहुँचा हुआ पड़ोसी नहीं।",
|
||||
"caveat": "peoplegroups.org के डेटा से अनुमान। कार्य करने से पहले स्थानीय वास्तविकता जाँचें।",
|
||||
"loadError": "जनसमूह डेटा लोड नहीं हो सका: {message}"
|
||||
},
|
||||
"camp": {
|
||||
"intro": "सेतु भाषाएँ: वे भाषाएँ जिन्हें विश्वासी और अपहुँच जनसमूह दोनों बोलते हैं। उस भाषा में एक अभियान, जहाँ विश्वासी रहते हैं वहाँ चलाकर, उन्हें अपहुँच लोगों तक उनकी अपनी भाषा में पहुँचने के लिए संगठित कर सकता है।",
|
||||
"searchPlaceholder": "भाषा या देश खोजें",
|
||||
"minBelievers": "कम से कम",
|
||||
"exportAll": "सूची निर्यात करें (CSV)",
|
||||
"unreachedPeople": "{n} अपहुँच",
|
||||
"believers": "~{n} विश्वासी",
|
||||
"believersIn": "विश्वासी यहाँ: {list}",
|
||||
"back": "सभी भाषाएँ",
|
||||
"bigBelievers": "संगठित करने योग्य विश्वासी ({g} समूह, {c} देश)",
|
||||
"bigUnreached": "पहुँचने योग्य अपहुँच लोग ({g} समूह, {c} देश)",
|
||||
"export": "यह अभियान निर्यात करें (CSV)",
|
||||
"whereToRun": "इसे कहाँ चलाएँ: देश के अनुसार विश्वासी",
|
||||
"believersInGroups": "~{n}, {g} समूहों में",
|
||||
"whoTheyReach": "वे किन तक पहुँच सकते हैं"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "बहु-दृश्य",
|
||||
"cap2": "प्रीसेट",
|
||||
"cap1": "एकल",
|
||||
"stageExperimental": "प्रायोगिक",
|
||||
"toggleList": "सूची दिखाएँ या छिपाएँ",
|
||||
"openViews": "दृश्यों की सूची खोलें",
|
||||
"expandList": "सूची फैलाएँ",
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Accesso nella propria lingua, per uso di internet del paese",
|
||||
"aria": "Grafici",
|
||||
"bandCaveat": "L’uso di internet è il dato del paese (Banca Mondiale), mai quello del gruppo.",
|
||||
"gapSub": "Gli 8 più grandi tra quelli mostrati, per popolazione. Clicca per aprirne uno.",
|
||||
"gapTake": "Solo questi {k} sono {people} persone.",
|
||||
"gapTitle": "I gruppi etnici più grandi senza niente nella propria lingua",
|
||||
"groups": "Gruppi",
|
||||
"groupsN": "{n} gruppi",
|
||||
"measure": "Conta",
|
||||
"messagingTop": "App di messaggistica principale, per paese selezionato",
|
||||
"notMeasured": "Non misurato",
|
||||
"ntSub": "Lingue dei gruppi mostrati senza Nuovo Testamento trovato, per numero di parlanti. Rosso: niente; giallo: qualcosa, ma nessun Nuovo Testamento.",
|
||||
"ntTake": "{n} lingue qui non hanno un Nuovo Testamento trovato, parlate da {people} persone.",
|
||||
"ntTitle": "Quali lingue hanno più bisogno del Nuovo Testamento?",
|
||||
"people": "Persone",
|
||||
"peopleN": "{n} persone",
|
||||
"q1sub": "Una barra per livello di accesso, per i gruppi etnici mostrati.",
|
||||
"q1take": "{n} dei {total} gruppi etnici mostrati non hanno niente nella propria lingua.",
|
||||
"q1title": "Che cosa hanno nella propria lingua?",
|
||||
"q2sub": "Gruppi etnici senza niente nella loro lingua, secondo quanto del loro paese è online.",
|
||||
"q2take": "{online} dei {n} senza niente vivono in paesi dove il 60 % o più usa internet.",
|
||||
"q2title": "Internet potrebbe portarlo a chi non ha niente?",
|
||||
"socialTop": "Piattaforma social principale, per paese selezionato",
|
||||
"unitCountries": "paesi",
|
||||
"unitGroups": "gruppi etnici",
|
||||
|
|
@ -49,6 +61,18 @@
|
|||
"translatedBy": "Tradotto da",
|
||||
"wayIn": "Via d’accesso"
|
||||
},
|
||||
"combos": {
|
||||
"audioOnline": "Ascoltano ma non leggono, paese online",
|
||||
"deaf": "Esiste un ponte in lingua dei segni",
|
||||
"digitalNow": "Niente nella loro lingua, paese online al 60 %+",
|
||||
"indiaChinaGap": "India e Cina: niente nella loro lingua",
|
||||
"none": "Tutto — scegli un filtro o una combinazione",
|
||||
"offlineGap": "Niente nella loro lingua, paese per lo più offline",
|
||||
"selection": "La tua selezione",
|
||||
"telegram": "Niente nella loro lingua, paese di Telegram",
|
||||
"try": "Prova una combinazione",
|
||||
"whatsapp": "Niente nella loro lingua, paese di WhatsApp"
|
||||
},
|
||||
"detail": {
|
||||
"hear": "Vedere o ascoltare, non leggere",
|
||||
"none": "Niente trovato",
|
||||
|
|
@ -107,6 +131,8 @@
|
|||
"count": "{n} di {total} gruppi etnici",
|
||||
"empty": "Nessun gruppo etnico corrisponde a questi filtri.",
|
||||
"exported": "{n} righe esportate (copiate e scaricate).",
|
||||
"openCapped": "Apre i primi {n} selezionati.",
|
||||
"openInGospel": "Apri in Risorse del Vangelo",
|
||||
"selectRow": "Seleziona {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
@ -186,6 +212,14 @@
|
|||
"languagesHere": "lingue qui",
|
||||
"legendTitle": "Nella propria lingua",
|
||||
"levels": "Famiglia › ramo › lingua › dialetto. Una famiglia o un ramo mostra il colore peggiore al suo interno. Un dialetto mostra il colore della sua lingua, più chiaro: le risorse sono catalogate per lingua, non per dialetto. Clicca un cerchio per restringere il pannello.",
|
||||
"poster": "Poster",
|
||||
"poster3ft": "3 × 3 piedi",
|
||||
"poster4ft": "4 × 4 piedi",
|
||||
"posterOmitted": "{n} nomi omessi dove si sovrapporrebbero a un altro; ogni cerchio è disegnato.",
|
||||
"posterPrint": "Stampa",
|
||||
"posterSizeAria": "Dimensione del poster",
|
||||
"posterSvg": "Scarica SVG",
|
||||
"posterTitle": "L’accesso al vangelo, famiglia linguistica per famiglia linguistica",
|
||||
"sources": "Albero: Glottolog (CC BY 4.0). Risorse: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, film JESUS, Global Recordings e altre, per ISO 639-3.",
|
||||
"st": {
|
||||
"g": "Nuovo Testamento o Bibbia completa trovati",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
},
|
||||
"upg100WallHeading": "La mia lista UPG 100 — Liste di preghiera e poster",
|
||||
"upg100WallTag": "Mobilitazione alla preghiera",
|
||||
"upg100WallIntro": "Distribuisci il compito della preghiera in gruppi di cinquanta e di cento — come Gesù sfamò i 5.000 (Luca 9:14). Ogni poster è una lista di 100 che puoi pregare, stampare e appendere al muro — pregando per loro <strong>con il nome del loro popolo</strong>.",
|
||||
"upg100WallIntroA": "Distribuisci il compito della preghiera in gruppi di cinquanta e di cento — come Gesù sfamò i 5.000 (Luca 9:14). Ogni poster è una lista di 100 che puoi pregare, stampare e appendere al muro — pregando per loro ",
|
||||
"upg100WallIntroEmph": "con il nome del loro popolo",
|
||||
"upg100WallIntroB": ".",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"sendMap": "Mappa di invio",
|
||||
"flowerMesh": "Rete floreale",
|
||||
"langCluster": "Gruppi linguistici",
|
||||
"flowers": "Fiori"
|
||||
"flowers": "Fiori",
|
||||
"mobilize": "Mobilitare",
|
||||
"campaigns": "Campagne"
|
||||
},
|
||||
"hints": {
|
||||
"cockpit": "centro dei filtri incrociati",
|
||||
|
|
@ -172,5 +174,80 @@
|
|||
"sm": {
|
||||
"counts": "{u} PNR · {s} mittenti raggiunti",
|
||||
"noSenders": "Nessun mittente raggiunto idoneo con un baricentro nazionale noto per questo obiettivo."
|
||||
},
|
||||
"about": {
|
||||
"button": "Come funziona",
|
||||
"title": "Mobilitare i vicini prossimi",
|
||||
"close": "Chiudi",
|
||||
"why": "I credenti più adatti a raggiungere un popolo non raggiunto sono di solito i più vicini a esso per lingua e cultura. Questo strumento li trova per ogni gruppo non raggiunto, con la scala E e la scala P di Ralph D. Winter, così puoi pianificare chi mobilitare, in quale lingua e dove.",
|
||||
"eTitle": "Scala E: la distanza che un testimone deve attraversare",
|
||||
"pTitle": "Scala P: la distanza tra un popolo e una chiesa",
|
||||
"dataTitle": "Dati e limiti",
|
||||
"data": "Raggiunto significa almeno il 2% di evangelici (definizione IMB). Lingua e cultura sono dedotte dal codice di lingua e dalla tassonomia dei popoli dell'IMB (cluster, blocco di affinità, famiglia linguistica): buone approssimazioni pubbliche, non valutazioni sul campo. Il numero di credenti è stimato da popolazione e fasce di evangelici; serve a dimensionare un pubblico, non come conteggio.",
|
||||
"source": "Dati sui popoli: peoplegroups.org (IMB), people_groups.csv. Scala E e scala P: Ralph D. Winter, Congresso di Losanna, 1974."
|
||||
},
|
||||
"e": {
|
||||
"short1": "E-1 · stessa lingua",
|
||||
"short2": "E-2 · cultura vicina",
|
||||
"short3": "E-3 · vicino, altra cultura",
|
||||
"title1": "E-1 · Parlano la stessa lingua",
|
||||
"title2": "E-2 · Una cultura vicina",
|
||||
"title3": "E-3 · Vicini, un’altra cultura",
|
||||
"long1": "Credenti che parlano la lingua di questo gruppo. Possono condividere il Vangelo senza barriere linguistiche, e una campagna in questa lingua può mobilitarli.",
|
||||
"long2": "Credenti di un popolo affine: lo stesso cluster di popoli, o la stessa famiglia linguistica nello stesso blocco di affinità. Un passo culturale più breve che partire da zero.",
|
||||
"long3": "Credenti che vivono vicino (entro 600 km o nello stesso paese) ma di cultura diversa. Condividono la regione, non la lingua."
|
||||
},
|
||||
"p": {
|
||||
"title0": "Nessun vicino raggiunto trovato",
|
||||
"title1": "Esistono credenti nella sua lingua",
|
||||
"title2": "I credenti più vicini sono di una cultura vicina",
|
||||
"title3": "I credenti più vicini sono di una cultura lontana",
|
||||
"long0": "In questi dati nessun gruppo raggiunto corrisponde a questo popolo per lingua, cultura o distanza.",
|
||||
"long1": "Da qualche parte un gruppo raggiunto parla la lingua di questo popolo. Mobilitarlo è la via più diretta.",
|
||||
"long2": "Nessun gruppo raggiunto parla questa lingua, ma popoli affini hanno credenti.",
|
||||
"long3": "Vicino ci sono solo credenti di un'altra cultura. Raggiungere questo popolo richiede operai interculturali."
|
||||
},
|
||||
"res": {
|
||||
"bible": "Bibbia",
|
||||
"film": "Film JESUS",
|
||||
"audio": "Audio",
|
||||
"radio": "Radio"
|
||||
},
|
||||
"mob": {
|
||||
"legend": "Legenda della mappa",
|
||||
"legendUnreached": "Gruppo non raggiunto",
|
||||
"searchPlaceholder": "Cerca un gruppo non raggiunto, paese o lingua",
|
||||
"pFilter": "Filtra per livello P",
|
||||
"pAll": "Tutti",
|
||||
"loading": "Caricamento dei popoli…",
|
||||
"intro": "Scegli un gruppo non raggiunto (prima i più grandi) o clicca un punto rosso sulla mappa per vedere chi mobilitare.",
|
||||
"noMatch": "Nessun risultato.",
|
||||
"back": "Tutti i gruppi",
|
||||
"people": "{n} persone",
|
||||
"believersNear": "Circa {n} credenti tra i vicini qui sotto.",
|
||||
"resources": "Risorse nella sua lingua",
|
||||
"export": "Esporta i segmenti di pubblico (CSV)",
|
||||
"openBridge": "Pianifica una campagna in {lang}",
|
||||
"samePeople": "stesso popolo",
|
||||
"cardStats": "~{b} credenti · a {km} km",
|
||||
"noNeighbors": "Nessun vicino raggiunto in questi dati.",
|
||||
"caveat": "Stime dai dati di peoplegroups.org. Verifica la realtà locale prima di agire.",
|
||||
"loadError": "Impossibile caricare i dati dei popoli: {message}"
|
||||
},
|
||||
"camp": {
|
||||
"intro": "Lingue ponte: lingue parlate da credenti e da popoli non raggiunti. Una campagna in quella lingua, rivolta dove vivono i credenti, può mobilitarli a raggiungere i non raggiunti nella loro lingua.",
|
||||
"searchPlaceholder": "Cerca una lingua o un paese",
|
||||
"minBelievers": "Almeno",
|
||||
"exportAll": "Esporta elenco (CSV)",
|
||||
"unreachedPeople": "{n} non raggiunti",
|
||||
"believers": "~{n} credenti",
|
||||
"believersIn": "Credenti in: {list}",
|
||||
"back": "Tutte le lingue",
|
||||
"bigBelievers": "credenti da mobilitare ({g} gruppi, {c} paesi)",
|
||||
"bigUnreached": "non raggiunti da raggiungere ({g} gruppi, {c} paesi)",
|
||||
"export": "Esporta questa campagna (CSV)",
|
||||
"whereToRun": "Dove lanciarla: credenti per paese",
|
||||
"believersInGroups": "~{n} in {g} gruppi",
|
||||
"whoTheyReach": "Chi possono raggiungere"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Multivista",
|
||||
"cap2": "Preimpostato",
|
||||
"cap1": "Singolo",
|
||||
"stageExperimental": "Sperimentale",
|
||||
"toggleList": "Mostra o nascondi l’elenco",
|
||||
"openViews": "Apri l’elenco delle viste",
|
||||
"expandList": "Espandi l’elenco",
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Acesso na própria língua, por uso de internet do país",
|
||||
"aria": "Gráficos",
|
||||
"bandCaveat": "O uso de internet é o número do país (Banco Mundial), nunca o do grupo.",
|
||||
"gapSub": "Os 8 maiores dos exibidos, por população. Clique num para abri-lo.",
|
||||
"gapTake": "Só estes {k} somam {people} pessoas.",
|
||||
"gapTitle": "Os maiores povos sem nada na própria língua",
|
||||
"groups": "Grupos",
|
||||
"groupsN": "{n} grupos",
|
||||
"measure": "Contar",
|
||||
"messagingTop": "App de mensagens principal, por país no recorte",
|
||||
"notMeasured": "Não medido",
|
||||
"ntSub": "Línguas dos grupos exibidos sem Novo Testamento encontrado, por quantas pessoas as falam. Vermelho: nada; amarelo: algo, mas sem Novo Testamento.",
|
||||
"ntTake": "{n} línguas aqui não têm Novo Testamento encontrado, faladas por {people} pessoas.",
|
||||
"ntTitle": "Quais línguas mais precisam do Novo Testamento?",
|
||||
"people": "Pessoas",
|
||||
"peopleN": "{n} pessoas",
|
||||
"q1sub": "Uma barra por nível de acesso, para os povos exibidos.",
|
||||
"q1take": "{n} de {total} povos exibidos não têm nada na própria língua.",
|
||||
"q1title": "O que eles têm na própria língua?",
|
||||
"q2sub": "Povos sem nada na própria língua, pelo quanto do país está online.",
|
||||
"q2take": "{online} dos {n} sem nada vivem em países onde 60 % ou mais usam a internet.",
|
||||
"q2title": "A internet poderia levar isso a quem não tem nada?",
|
||||
"socialTop": "Rede social principal, por país no recorte",
|
||||
"unitCountries": "países",
|
||||
"unitGroups": "povos",
|
||||
|
|
@ -49,6 +61,18 @@
|
|||
"translatedBy": "Traduzido por",
|
||||
"wayIn": "Caminho de entrada"
|
||||
},
|
||||
"combos": {
|
||||
"audioOnline": "Ouvem mas não leem, país online",
|
||||
"deaf": "Existe uma ponte em língua de sinais",
|
||||
"digitalNow": "Nada na língua deles, país 60 %+ online",
|
||||
"indiaChinaGap": "Índia e China: nada na língua deles",
|
||||
"none": "Tudo — escolha um filtro ou uma combinação",
|
||||
"offlineGap": "Nada na língua deles, país quase todo offline",
|
||||
"selection": "Sua seleção",
|
||||
"telegram": "Nada na língua deles, país do Telegram",
|
||||
"try": "Experimente uma combinação",
|
||||
"whatsapp": "Nada na língua deles, país do WhatsApp"
|
||||
},
|
||||
"detail": {
|
||||
"hear": "Ver ou ouvir, sem ler",
|
||||
"none": "Nada encontrado",
|
||||
|
|
@ -107,6 +131,8 @@
|
|||
"count": "{n} de {total} povos",
|
||||
"empty": "Nenhum povo corresponde a estes filtros.",
|
||||
"exported": "{n} linhas exportadas (copiadas e baixadas).",
|
||||
"openCapped": "Abre os primeiros {n} selecionados.",
|
||||
"openInGospel": "Abrir em Recursos do Evangelho",
|
||||
"selectRow": "Selecionar {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
@ -186,6 +212,14 @@
|
|||
"languagesHere": "línguas aqui",
|
||||
"legendTitle": "Na própria língua",
|
||||
"levels": "Família › ramo › língua › dialeto. Uma família ou ramo mostra a pior cor que contém. Um dialeto mostra a cor da sua língua, mais clara: os recursos são catalogados por língua, não por dialeto. Clique num círculo para restringir o painel a ele.",
|
||||
"poster": "Pôster",
|
||||
"poster3ft": "3 × 3 pés",
|
||||
"poster4ft": "4 × 4 pés",
|
||||
"posterOmitted": "{n} nomes omitidos onde sobreporiam outro; todo círculo está desenhado.",
|
||||
"posterPrint": "Imprimir",
|
||||
"posterSizeAria": "Tamanho do pôster",
|
||||
"posterSvg": "Baixar SVG",
|
||||
"posterTitle": "Acesso ao evangelho, família linguística por família linguística",
|
||||
"sources": "Árvore: Glottolog (CC BY 4.0). Recursos: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, filme JESUS, Global Recordings e outros, por ISO 639-3.",
|
||||
"st": {
|
||||
"g": "Novo Testamento ou Bíblia completa encontrados",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
},
|
||||
"upg100WallHeading": "A minha lista UPG 100 — Listas de oração e cartazes",
|
||||
"upg100WallTag": "Mobilização de oração",
|
||||
"upg100WallIntro": "Reparta a tarefa da oração em grupos de cinquenta e de cem — como Jesus alimentou os 5.000 (Lucas 9:14). Cada cartaz é uma lista de 100 que pode orar, imprimir e pôr na parede — orando por eles <strong>pelo nome do seu povo</strong>.",
|
||||
"upg100WallIntroA": "Reparta a tarefa da oração em grupos de cinquenta e de cem — como Jesus alimentou os 5.000 (Lucas 9:14). Cada cartaz é uma lista de 100 que pode orar, imprimir e pôr na parede — orando por eles ",
|
||||
"upg100WallIntroEmph": "pelo nome do seu povo",
|
||||
"upg100WallIntroB": ".",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"sendMap": "Mapa de envio",
|
||||
"flowerMesh": "Malha floral",
|
||||
"langCluster": "Grupos linguísticos",
|
||||
"flowers": "Flores"
|
||||
"flowers": "Flores",
|
||||
"mobilize": "Mobilizar",
|
||||
"campaigns": "Campanhas"
|
||||
},
|
||||
"hints": {
|
||||
"cockpit": "centro de filtros cruzados",
|
||||
|
|
@ -172,5 +174,80 @@
|
|||
"sm": {
|
||||
"counts": "{u} PNA · {s} enviadores alcançados",
|
||||
"noSenders": "Nenhum enviador alcançado qualificado com um centroide de país conhecido para este alvo."
|
||||
},
|
||||
"about": {
|
||||
"button": "Como funciona",
|
||||
"title": "Mobilizar os vizinhos próximos",
|
||||
"close": "Fechar",
|
||||
"why": "Os crentes mais bem posicionados para alcançar um povo não alcançado costumam ser os mais próximos dele em língua e cultura. Esta ferramenta encontra-os para cada grupo não alcançado, com a escala E e a escala P de Ralph D. Winter, para que possa planear quem mobilizar, em que língua e onde.",
|
||||
"eTitle": "Escala E: a distância que uma testemunha tem de atravessar",
|
||||
"pTitle": "Escala P: a distância entre um povo e uma igreja",
|
||||
"dataTitle": "Dados e limites",
|
||||
"data": "Alcançado significa pelo menos 2% de evangélicos (definição do IMB). A língua e a cultura são deduzidas do código de língua e da taxonomia de povos do IMB (agrupamento, bloco de afinidade, família linguística): boas aproximações públicas, não avaliações de campo. O número de crentes é estimado a partir da população e das faixas de evangélicos; serve para dimensionar uma audiência, não como contagem.",
|
||||
"source": "Dados de povos: peoplegroups.org (IMB), people_groups.csv. Escala E e escala P: Ralph D. Winter, Congresso de Lausanne, 1974."
|
||||
},
|
||||
"e": {
|
||||
"short1": "E-1 · mesma língua",
|
||||
"short2": "E-2 · cultura próxima",
|
||||
"short3": "E-3 · perto, outra cultura",
|
||||
"title1": "E-1 · Falam a mesma língua",
|
||||
"title2": "E-2 · Uma cultura próxima",
|
||||
"title3": "E-3 · Perto, outra cultura",
|
||||
"long1": "Crentes que falam a língua deste grupo. Podem partilhar o evangelho sem atravessar uma barreira linguística, e uma campanha nesta língua pode mobilizá-los.",
|
||||
"long2": "Crentes de um povo aparentado: o mesmo agrupamento de povos, ou a mesma família linguística no mesmo bloco de afinidade. Um passo cultural mais curto do que começar do zero.",
|
||||
"long3": "Crentes que vivem perto (a menos de 600 km ou no mesmo país) mas de cultura diferente. Partilham a região, não a língua."
|
||||
},
|
||||
"p": {
|
||||
"title0": "Nenhum vizinho alcançado encontrado",
|
||||
"title1": "Existem crentes na sua língua",
|
||||
"title2": "Os crentes mais próximos são de uma cultura próxima",
|
||||
"title3": "Os crentes mais próximos são de uma cultura distante",
|
||||
"long0": "Nestes dados nenhum grupo alcançado corresponde a este povo por língua, cultura ou distância.",
|
||||
"long1": "Algures, um grupo alcançado fala a língua deste povo. Mobilizá-lo é o caminho mais direto.",
|
||||
"long2": "Nenhum grupo alcançado fala esta língua, mas povos aparentados têm crentes.",
|
||||
"long3": "Perto só há crentes de outra cultura. Alcançar este povo exige obreiros transculturais."
|
||||
},
|
||||
"res": {
|
||||
"bible": "Bíblia",
|
||||
"film": "Filme JESUS",
|
||||
"audio": "Áudio",
|
||||
"radio": "Rádio"
|
||||
},
|
||||
"mob": {
|
||||
"legend": "Legenda do mapa",
|
||||
"legendUnreached": "Grupo não alcançado",
|
||||
"searchPlaceholder": "Pesquise um grupo não alcançado, país ou língua",
|
||||
"pFilter": "Filtrar por nível P",
|
||||
"pAll": "Todos",
|
||||
"loading": "A carregar povos…",
|
||||
"intro": "Escolha um grupo não alcançado (os maiores primeiro) ou clique num ponto vermelho no mapa para ver quem mobilizar.",
|
||||
"noMatch": "Sem resultados.",
|
||||
"back": "Todos os grupos",
|
||||
"people": "{n} pessoas",
|
||||
"believersNear": "Cerca de {n} crentes entre os vizinhos abaixo.",
|
||||
"resources": "Recursos na sua língua",
|
||||
"export": "Exportar segmentos de público (CSV)",
|
||||
"openBridge": "Planear uma campanha em {lang}",
|
||||
"samePeople": "mesmo povo",
|
||||
"cardStats": "~{b} crentes · a {km} km",
|
||||
"noNeighbors": "Nenhum vizinho alcançado nestes dados.",
|
||||
"caveat": "Estimativas a partir de dados de peoplegroups.org. Verifique a realidade local antes de agir.",
|
||||
"loadError": "Não foi possível carregar os dados dos povos: {message}"
|
||||
},
|
||||
"camp": {
|
||||
"intro": "Línguas-ponte: línguas faladas por crentes e por povos não alcançados. Uma campanha nessa língua, dirigida onde vivem os crentes, pode mobilizá-los para alcançar os não alcançados na sua própria língua.",
|
||||
"searchPlaceholder": "Pesquise uma língua ou país",
|
||||
"minBelievers": "Pelo menos",
|
||||
"exportAll": "Exportar lista (CSV)",
|
||||
"unreachedPeople": "{n} não alcançados",
|
||||
"believers": "~{n} crentes",
|
||||
"believersIn": "Crentes em: {list}",
|
||||
"back": "Todas as línguas",
|
||||
"bigBelievers": "crentes a mobilizar ({g} grupos, {c} países)",
|
||||
"bigUnreached": "não alcançados a alcançar ({g} grupos, {c} países)",
|
||||
"export": "Exportar esta campanha (CSV)",
|
||||
"whereToRun": "Onde lançá-la: crentes por país",
|
||||
"believersInGroups": "~{n} em {g} grupos",
|
||||
"whoTheyReach": "Quem podem alcançar"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Multivisualização",
|
||||
"cap2": "Predefinição",
|
||||
"cap1": "Único",
|
||||
"stageExperimental": "Experimental",
|
||||
"toggleList": "Mostrar ou ocultar a lista",
|
||||
"openViews": "Abrir a lista de visualizações",
|
||||
"expandList": "Expandir a lista",
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Acces în propria limbă, după folosirea internetului în țară",
|
||||
"aria": "Grafice",
|
||||
"bandCaveat": "Folosirea internetului este cifra țării (Banca Mondială), niciodată a grupului.",
|
||||
"gapSub": "Cele mai mari 8 dintre cele afișate, după populație. Faceți clic pentru a deschide unul.",
|
||||
"gapTake": "Doar aceste {k} înseamnă {people} oameni.",
|
||||
"gapTitle": "Cele mai mari grupuri etnice fără nimic în propria limbă",
|
||||
"groups": "Grupuri",
|
||||
"groupsN": "{n} grupuri",
|
||||
"measure": "Numără",
|
||||
"messagingTop": "Aplicația de mesagerie principală, pe țări selectate",
|
||||
"notMeasured": "Nemăsurat",
|
||||
"ntSub": "Limbile grupurilor afișate fără Noul Testament găsit, după numărul vorbitorilor. Roșu: nimic; galben: ceva, dar fără Noul Testament.",
|
||||
"ntTake": "{n} limbi de aici nu au Noul Testament găsit, vorbite de {people} oameni.",
|
||||
"ntTitle": "Ce limbi au cea mai mare nevoie de Noul Testament?",
|
||||
"people": "Oameni",
|
||||
"peopleN": "{n} oameni",
|
||||
"q1sub": "O bară pentru fiecare nivel de acces, pentru grupurile etnice afișate.",
|
||||
"q1take": "{n} din {total} grupuri etnice afișate nu au nimic în propria limbă.",
|
||||
"q1title": "Ce au în propria lor limbă?",
|
||||
"q2sub": "Grupuri etnice fără nimic în limba lor, după cât din țara lor este online.",
|
||||
"q2take": "{online} din cele {n} fără nimic trăiesc în țări unde 60 % sau mai mult folosesc internetul.",
|
||||
"q2title": "Ar putea internetul să-l ducă celor care nu au nimic?",
|
||||
"socialTop": "Platforma socială principală, pe țări selectate",
|
||||
"unitCountries": "țări",
|
||||
"unitGroups": "grupuri etnice",
|
||||
|
|
@ -49,6 +61,18 @@
|
|||
"translatedBy": "Tradus de",
|
||||
"wayIn": "Cale de acces"
|
||||
},
|
||||
"combos": {
|
||||
"audioOnline": "Aud, dar nu citesc, țară online",
|
||||
"deaf": "Există o punte în limbajul semnelor",
|
||||
"digitalNow": "Nimic în limba lor, țară online 60 %+",
|
||||
"indiaChinaGap": "India și China: nimic în limba lor",
|
||||
"none": "Totul — alegeți un filtru sau o combinație",
|
||||
"offlineGap": "Nimic în limba lor, țară mai mult offline",
|
||||
"selection": "Selecția dvs.",
|
||||
"telegram": "Nimic în limba lor, țară Telegram",
|
||||
"try": "Încercați o combinație",
|
||||
"whatsapp": "Nimic în limba lor, țară WhatsApp"
|
||||
},
|
||||
"detail": {
|
||||
"hear": "Văd sau aud, nu citesc",
|
||||
"none": "Nimic găsit",
|
||||
|
|
@ -107,6 +131,8 @@
|
|||
"count": "{n} din {total} grupuri etnice",
|
||||
"empty": "Niciun grup etnic nu corespunde acestor filtre.",
|
||||
"exported": "{n} rânduri exportate (copiate și descărcate).",
|
||||
"openCapped": "Deschide primele {n} selectate.",
|
||||
"openInGospel": "Deschide în Resurse ale Evangheliei",
|
||||
"selectRow": "Selectează {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
@ -186,6 +212,14 @@
|
|||
"languagesHere": "limbi aici",
|
||||
"legendTitle": "În propria lor limbă",
|
||||
"levels": "Familie › ramură › limbă › dialect. O familie sau o ramură arată cea mai rea culoare din interior. Un dialect arată culoarea limbii sale, mai deschisă: resursele sunt catalogate pe limbă, nu pe dialect. Faceți clic pe un cerc pentru a restrânge panoul la el.",
|
||||
"poster": "Poster",
|
||||
"poster3ft": "3 × 3 ft",
|
||||
"poster4ft": "4 × 4 ft",
|
||||
"posterOmitted": "{n} nume omise acolo unde s-ar suprapune peste altul; fiecare cerc este desenat.",
|
||||
"posterPrint": "Tipărește",
|
||||
"posterSizeAria": "Dimensiunea posterului",
|
||||
"posterSvg": "Descarcă SVG",
|
||||
"posterTitle": "Accesul la Evanghelie, familie de limbi cu familie de limbi",
|
||||
"sources": "Arbore: Glottolog (CC BY 4.0). Resurse: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, filmul JESUS, Global Recordings și altele, după ISO 639-3.",
|
||||
"st": {
|
||||
"g": "Noul Testament sau Biblia întreagă găsite",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
},
|
||||
"upg100WallHeading": "Lista mea UPG 100 — Liste de rugăciune și afișe",
|
||||
"upg100WallTag": "Mobilizare pentru rugăciune",
|
||||
"upg100WallIntro": "Împarte sarcina rugăciunii în grupuri de câte cincizeci și o sută — așa cum Isus a hrănit cei 5.000 (Luca 9:14). Fiecare afiș este o listă de 100 pe care o poți ruga, tipări și pune pe perete — rugându-te pentru ei <strong>pe numele poporului lor</strong>.",
|
||||
"upg100WallIntroA": "Împarte sarcina rugăciunii în grupuri de câte cincizeci și o sută — așa cum Isus a hrănit cei 5.000 (Luca 9:14). Fiecare afiș este o listă de 100 pe care o poți ruga, tipări și pune pe perete — rugându-te pentru ei ",
|
||||
"upg100WallIntroEmph": "pe numele poporului lor",
|
||||
"upg100WallIntroB": ".",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"sendMap": "Harta de trimitere",
|
||||
"flowerMesh": "Rețea florală",
|
||||
"langCluster": "Grupuri lingvistice",
|
||||
"flowers": "Flori"
|
||||
"flowers": "Flori",
|
||||
"mobilize": "Mobilizare",
|
||||
"campaigns": "Campanii"
|
||||
},
|
||||
"hints": {
|
||||
"cockpit": "centru de filtrare încrucișată",
|
||||
|
|
@ -172,5 +174,80 @@
|
|||
"sm": {
|
||||
"counts": "{u} UPG · {s} trimițători atinși",
|
||||
"noSenders": "Niciun trimițător atins eligibil cu un centroid de țară cunoscut pentru această țintă."
|
||||
},
|
||||
"about": {
|
||||
"button": "Cum funcționează",
|
||||
"title": "Mobilizarea vecinilor apropiați",
|
||||
"close": "Închide",
|
||||
"why": "Credincioșii cei mai potriviți să ajungă la un popor neatins sunt de obicei cei mai apropiați de el prin limbă și cultură. Acest instrument îi găsește pentru fiecare grup neatins, folosind scala E și scala P ale lui Ralph D. Winter, ca să puteți planifica pe cine să mobilizați, în ce limbă și unde.",
|
||||
"eTitle": "Scala E: distanța pe care trebuie să o traverseze un martor",
|
||||
"pTitle": "Scala P: distanța dintre un popor și o biserică",
|
||||
"dataTitle": "Date și limite",
|
||||
"data": "Atins înseamnă cel puțin 2% evanghelici (definiția IMB). Limba și cultura sunt deduse din codul limbii și din taxonomia popoarelor IMB (grup, bloc de afinitate, familie de limbi): aproximări publice bune, nu evaluări de teren. Numărul credincioșilor este estimat din populație și intervalele de evanghelici; folosiți-l pentru a dimensiona o audiență, nu ca numărătoare.",
|
||||
"source": "Date despre popoare: peoplegroups.org (IMB), people_groups.csv. Scala E și scala P: Ralph D. Winter, Congresul de la Lausanne, 1974."
|
||||
},
|
||||
"e": {
|
||||
"short1": "E-1 · aceeași limbă",
|
||||
"short2": "E-2 · cultură apropiată",
|
||||
"short3": "E-3 · aproape, altă cultură",
|
||||
"title1": "E-1 · Vorbesc aceeași limbă",
|
||||
"title2": "E-2 · O cultură apropiată",
|
||||
"title3": "E-3 · Aproape, o altă cultură",
|
||||
"long1": "Credincioși care vorbesc limba acestui grup. Pot împărtăși Evanghelia fără o barieră de limbă, iar o campanie în această limbă îi poate mobiliza.",
|
||||
"long2": "Credincioși dintr-un popor înrudit: același grup de popoare sau aceeași familie de limbi în același bloc de afinitate. Un pas cultural mai scurt decât a începe de la zero.",
|
||||
"long3": "Credincioși care locuiesc aproape (sub 600 km sau în aceeași țară), dar de altă cultură. Împart regiunea, nu limba."
|
||||
},
|
||||
"p": {
|
||||
"title0": "Niciun vecin atins găsit",
|
||||
"title1": "Există credincioși în limba sa",
|
||||
"title2": "Cei mai apropiați credincioși sunt dintr-o cultură apropiată",
|
||||
"title3": "Cei mai apropiați credincioși sunt dintr-o cultură îndepărtată",
|
||||
"long0": "În aceste date niciun grup atins nu se potrivește cu acest popor ca limbă, cultură sau distanță.",
|
||||
"long1": "Undeva, un grup atins vorbește limba acestui popor. Mobilizarea lui este calea cea mai directă.",
|
||||
"long2": "Niciun grup atins nu vorbește această limbă, dar popoarele înrudite au credincioși.",
|
||||
"long3": "În apropiere sunt doar credincioși de altă cultură. Pentru a ajunge la acest popor e nevoie de lucrători interculturali."
|
||||
},
|
||||
"res": {
|
||||
"bible": "Biblie",
|
||||
"film": "Filmul ISUS",
|
||||
"audio": "Audio",
|
||||
"radio": "Radio"
|
||||
},
|
||||
"mob": {
|
||||
"legend": "Legenda hărții",
|
||||
"legendUnreached": "Grup neatins",
|
||||
"searchPlaceholder": "Căutați un grup neatins, o țară sau o limbă",
|
||||
"pFilter": "Filtrați după nivelul P",
|
||||
"pAll": "Toate",
|
||||
"loading": "Se încarcă popoarele…",
|
||||
"intro": "Alegeți un grup neatins (cele mai mari primele) sau dați clic pe un punct roșu de pe hartă pentru a vedea pe cine să mobilizați.",
|
||||
"noMatch": "Niciun rezultat.",
|
||||
"back": "Toate grupurile",
|
||||
"people": "{n} oameni",
|
||||
"believersNear": "Aproximativ {n} credincioși printre vecinii de mai jos.",
|
||||
"resources": "Resurse în limba sa",
|
||||
"export": "Exportați segmentele de public (CSV)",
|
||||
"openBridge": "Planificați o campanie în {lang}",
|
||||
"samePeople": "același popor",
|
||||
"cardStats": "~{b} credincioși · la {km} km",
|
||||
"noNeighbors": "Niciun vecin atins în aceste date.",
|
||||
"caveat": "Estimări din datele peoplegroups.org. Verificați realitatea locală înainte de a acționa.",
|
||||
"loadError": "Datele despre popoare nu au putut fi încărcate: {message}"
|
||||
},
|
||||
"camp": {
|
||||
"intro": "Limbi-punte: limbi vorbite atât de credincioși, cât și de popoare neatinse. O campanie în acea limbă, difuzată acolo unde trăiesc credincioșii, îi poate mobiliza să ajungă la cei neatinși în propria limbă.",
|
||||
"searchPlaceholder": "Căutați o limbă sau o țară",
|
||||
"minBelievers": "Cel puțin",
|
||||
"exportAll": "Exportați lista (CSV)",
|
||||
"unreachedPeople": "{n} neatinși",
|
||||
"believers": "~{n} credincioși",
|
||||
"believersIn": "Credincioși în: {list}",
|
||||
"back": "Toate limbile",
|
||||
"bigBelievers": "credincioși de mobilizat ({g} grupuri, {c} țări)",
|
||||
"bigUnreached": "neatinși de atins ({g} grupuri, {c} țări)",
|
||||
"export": "Exportați această campanie (CSV)",
|
||||
"whereToRun": "Unde s-o derulați: credincioși pe țări",
|
||||
"believersInGroups": "~{n} în {g} grupuri",
|
||||
"whoTheyReach": "Pe cine pot atinge"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Vizualizări multiple",
|
||||
"cap2": "Presetat",
|
||||
"cap1": "Simplu",
|
||||
"stageExperimental": "Experimental",
|
||||
"toggleList": "Afișează sau ascunde lista",
|
||||
"openViews": "Deschide lista de vizualizări",
|
||||
"expandList": "Extinde lista",
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "Доступ на родном языке по уровню интернета в стране",
|
||||
"aria": "Диаграммы",
|
||||
"bandCaveat": "Использование интернета — показатель страны (Всемирный банк), а не группы.",
|
||||
"gapSub": "8 крупнейших из показанных, по численности. Нажмите, чтобы открыть.",
|
||||
"gapTake": "Только эти {k} — это {people} человек.",
|
||||
"gapTitle": "Крупнейшие народы, у которых нет ничего на родном языке",
|
||||
"groups": "Группы",
|
||||
"groupsN": "{n} групп",
|
||||
"measure": "Считать",
|
||||
"messagingTop": "Главный мессенджер по странам в выборке",
|
||||
"notMeasured": "Не измерено",
|
||||
"ntSub": "Языки показанных групп без найденного Нового Завета, по числу говорящих. Красный: ничего; жёлтый: что-то есть, но нет Нового Завета.",
|
||||
"ntTake": "Языков здесь без найденного Нового Завета: {n}; на них говорят {people} человек.",
|
||||
"ntTitle": "Каким языкам больше всего нужен Новый Завет?",
|
||||
"people": "Люди",
|
||||
"peopleN": "{n} чел.",
|
||||
"q1sub": "Одна полоса на уровень доступа, для показанных народов.",
|
||||
"q1take": "У {n} из {total} показанных народов нет ничего на родном языке.",
|
||||
"q1title": "Что у них есть на родном языке?",
|
||||
"q2sub": "Народы, у которых нет ничего на их языке, по доле их страны в сети.",
|
||||
"q2take": "{online} из {n} без ничего живут в странах, где интернетом пользуются 60 % и более.",
|
||||
"q2title": "Может ли интернет донести это до тех, у кого ничего нет?",
|
||||
"socialTop": "Главная соцсеть по странам в выборке",
|
||||
"unitCountries": "стран",
|
||||
"unitGroups": "народов",
|
||||
|
|
@ -49,6 +61,18 @@
|
|||
"translatedBy": "Перевод",
|
||||
"wayIn": "Путь"
|
||||
},
|
||||
"combos": {
|
||||
"audioOnline": "Слышат, но не читают, страна в сети",
|
||||
"deaf": "Есть мост на жестовом языке",
|
||||
"digitalNow": "Ничего на их языке, страна в сети на 60 %+",
|
||||
"indiaChinaGap": "Индия и Китай: ничего на их языке",
|
||||
"none": "Всё — выберите фильтр или сочетание",
|
||||
"offlineGap": "Ничего на их языке, страна в основном офлайн",
|
||||
"selection": "Ваш выбор",
|
||||
"telegram": "Ничего на их языке, страна Telegram",
|
||||
"try": "Попробуйте сочетание",
|
||||
"whatsapp": "Ничего на их языке, страна WhatsApp"
|
||||
},
|
||||
"detail": {
|
||||
"hear": "Смотрят или слушают, не читают",
|
||||
"none": "Ничего не найдено",
|
||||
|
|
@ -107,6 +131,8 @@
|
|||
"count": "{n} из {total} народов",
|
||||
"empty": "Ни один народ не подходит под эти фильтры.",
|
||||
"exported": "Экспортировано строк: {n} (скопировано и скачано).",
|
||||
"openCapped": "Открывает первые {n} выбранных.",
|
||||
"openInGospel": "Открыть в «Евангельских ресурсах»",
|
||||
"selectRow": "Выбрать: {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
@ -186,6 +212,14 @@
|
|||
"languagesHere": "языков здесь",
|
||||
"legendTitle": "На родном языке",
|
||||
"levels": "Семья › ветвь › язык › диалект. Семья или ветвь показывает худший цвет внутри. Диалект показывает цвет своего языка, светлее: ресурсы учитываются по языкам, а не по диалектам. Нажмите на круг, чтобы сузить панель до него.",
|
||||
"poster": "Постер",
|
||||
"poster3ft": "3 × 3 фута",
|
||||
"poster4ft": "4 × 4 фута",
|
||||
"posterOmitted": "Пропущено названий: {n} — там, где они перекрыли бы другие; каждый круг нарисован.",
|
||||
"posterPrint": "Печать",
|
||||
"posterSizeAria": "Размер постера",
|
||||
"posterSvg": "Скачать SVG",
|
||||
"posterTitle": "Доступ к Евангелию: языковая семья за языковой семьёй",
|
||||
"sources": "Дерево: Glottolog (CC BY 4.0). Ресурсы: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, фильм «Иисус», Global Recordings и другие, по ISO 639-3.",
|
||||
"st": {
|
||||
"g": "Найден Новый Завет или вся Библия",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
},
|
||||
"upg100WallHeading": "Мой список UPG 100 — молитвенные списки и плакаты",
|
||||
"upg100WallTag": "Молитвенная мобилизация",
|
||||
"upg100WallIntro": "Разделите молитвенный труд на группы по пятьдесят и по сто — так Иисус накормил 5000 (Луки 9:14). Каждый плакат — это список из 100, о котором можно молиться, распечатать и повесить на стену, молясь о них <strong>по имени их народа</strong>.",
|
||||
"upg100WallIntroA": "Разделите молитвенный труд на группы по пятьдесят и по сто — так Иисус накормил 5000 (Луки 9:14). Каждый плакат — это список из 100, о котором можно молиться, распечатать и повесить на стену, молясь о них ",
|
||||
"upg100WallIntroEmph": "по имени их народа",
|
||||
"upg100WallIntroB": ".",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"sendMap": "Карта отправки",
|
||||
"flowerMesh": "Цветочная сеть",
|
||||
"langCluster": "Языковые кластеры",
|
||||
"flowers": "Цветы"
|
||||
"flowers": "Цветы",
|
||||
"mobilize": "Мобилизация",
|
||||
"campaigns": "Кампании"
|
||||
},
|
||||
"hints": {
|
||||
"cockpit": "центр перекрёстной фильтрации",
|
||||
|
|
@ -172,5 +174,80 @@
|
|||
"sm": {
|
||||
"counts": "{u} НН · {s} достигнутых отправителей",
|
||||
"noSenders": "Нет подходящих достигнутых отправителей с известным центром страны для этой цели."
|
||||
},
|
||||
"about": {
|
||||
"button": "Как это работает",
|
||||
"title": "Мобилизация близких соседей",
|
||||
"close": "Закрыть",
|
||||
"why": "Лучше всего достучаться до непросвещённого народа обычно могут верующие, самые близкие к нему по языку и культуре. Этот инструмент находит их для каждой непросвещённой группы по шкалам E и P Ральфа Д. Уинтера, чтобы вы могли спланировать, кого мобилизовать, на каком языке и где.",
|
||||
"eTitle": "Шкала E: какое расстояние должен преодолеть свидетель",
|
||||
"pTitle": "Шкала P: расстояние между народом и церковью",
|
||||
"dataTitle": "Данные и ограничения",
|
||||
"data": "Просвещённым считается народ, где не менее 2% евангельских христиан (определение IMB). Язык и культура определяются по коду языка и таксономии народов IMB (кластер, блок родства, языковая семья): хорошие общедоступные приближения, а не полевые оценки. Число верующих оценено по численности населения и диапазонам доли евангельских христиан; используйте его для оценки аудитории, а не как точный подсчёт.",
|
||||
"source": "Данные о народах: peoplegroups.org (IMB), people_groups.csv. Шкалы E и P: Ральф Д. Уинтер, Лозаннский конгресс, 1974."
|
||||
},
|
||||
"e": {
|
||||
"short1": "E-1 · тот же язык",
|
||||
"short2": "E-2 · близкая культура",
|
||||
"short3": "E-3 · рядом, другая культура",
|
||||
"title1": "E-1 · Они говорят на том же языке",
|
||||
"title2": "E-2 · Близкая культура",
|
||||
"title3": "E-3 · Рядом, другая культура",
|
||||
"long1": "Верующие, говорящие на языке этой группы. Они могут делиться Евангелием без языкового барьера, а кампания на этом языке может их мобилизовать.",
|
||||
"long2": "Верующие из родственного народа: тот же кластер народов или та же языковая семья в том же блоке родства. Культурный шаг короче, чем начинать с нуля.",
|
||||
"long3": "Верующие, живущие рядом (в пределах 600 км или в той же стране), но другой культуры. Их объединяет регион, а не язык."
|
||||
},
|
||||
"p": {
|
||||
"title0": "Просвещённые соседи не найдены",
|
||||
"title1": "Есть верующие на его языке",
|
||||
"title2": "Ближайшие верующие из близкой культуры",
|
||||
"title3": "Ближайшие верующие из далёкой культуры",
|
||||
"long0": "В этих данных ни одна просвещённая группа не совпадает с этим народом по языку, культуре или расстоянию.",
|
||||
"long1": "Где-то просвещённая группа говорит на языке этого народа. Мобилизовать её — самый прямой путь.",
|
||||
"long2": "Ни одна просвещённая группа не говорит на этом языке, но у родственных народов есть верующие.",
|
||||
"long3": "Рядом только верующие другой культуры. Чтобы достичь этот народ, нужны межкультурные служители."
|
||||
},
|
||||
"res": {
|
||||
"bible": "Библия",
|
||||
"film": "Фильм «Иисус»",
|
||||
"audio": "Аудио",
|
||||
"radio": "Радио"
|
||||
},
|
||||
"mob": {
|
||||
"legend": "Легенда карты",
|
||||
"legendUnreached": "Непросвещённая группа",
|
||||
"searchPlaceholder": "Поиск непросвещённой группы, страны или языка",
|
||||
"pFilter": "Фильтр по уровню P",
|
||||
"pAll": "Все",
|
||||
"loading": "Загрузка народов…",
|
||||
"intro": "Выберите непросвещённую группу (сначала крупные) или нажмите на красную точку на карте, чтобы увидеть, кого мобилизовать.",
|
||||
"noMatch": "Ничего не найдено.",
|
||||
"back": "Все группы",
|
||||
"people": "{n} чел.",
|
||||
"believersNear": "Около {n} верующих среди соседей ниже.",
|
||||
"resources": "Материалы на его языке",
|
||||
"export": "Экспорт сегментов аудитории (CSV)",
|
||||
"openBridge": "Спланировать кампанию на языке {lang}",
|
||||
"samePeople": "тот же народ",
|
||||
"cardStats": "~{b} верующих · {km} км",
|
||||
"noNeighbors": "В этих данных нет просвещённых соседей.",
|
||||
"caveat": "Оценки по данным peoplegroups.org. Проверьте местные реалии, прежде чем действовать.",
|
||||
"loadError": "Не удалось загрузить данные о народах: {message}"
|
||||
},
|
||||
"camp": {
|
||||
"intro": "Языки-мосты: языки, на которых говорят и верующие, и непросвещённые народы. Одна кампания на этом языке там, где живут верующие, может мобилизовать их, чтобы достичь непросвещённых на их родном языке.",
|
||||
"searchPlaceholder": "Поиск языка или страны",
|
||||
"minBelievers": "Не менее",
|
||||
"exportAll": "Экспорт списка (CSV)",
|
||||
"unreachedPeople": "{n} непросвещённых",
|
||||
"believers": "~{n} верующих",
|
||||
"believersIn": "Верующие в: {list}",
|
||||
"back": "Все языки",
|
||||
"bigBelievers": "верующих для мобилизации ({g} групп, {c} стран)",
|
||||
"bigUnreached": "непросвещённых, которых можно достичь ({g} групп, {c} стран)",
|
||||
"export": "Экспорт этой кампании (CSV)",
|
||||
"whereToRun": "Где её проводить: верующие по странам",
|
||||
"believersInGroups": "~{n} в {g} группах",
|
||||
"whoTheyReach": "Кого они могут достичь"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Несколько видов",
|
||||
"cap2": "Предустановка",
|
||||
"cap1": "Одиночный",
|
||||
"stageExperimental": "Экспериментальные",
|
||||
"toggleList": "Показать или скрыть список",
|
||||
"openViews": "Открыть список представлений",
|
||||
"expandList": "Развернуть список",
|
||||
|
|
|
|||
|
|
@ -14,13 +14,25 @@
|
|||
"accessByBand": "母语资源情况,按国家互联网使用率",
|
||||
"aria": "图表",
|
||||
"bandCaveat": "互联网使用率是国家的数字(世界银行),不是该族群的数字。",
|
||||
"gapSub": "所显示族群中人口最多的 8 个。点击即可打开。",
|
||||
"gapTake": "仅这 {k} 个就有 {people} 人。",
|
||||
"gapTitle": "母语中一无所有的最大族群",
|
||||
"groups": "族群",
|
||||
"groupsN": "{n} 个族群",
|
||||
"measure": "统计",
|
||||
"messagingTop": "各国(当前范围)最主要的通讯应用",
|
||||
"notMeasured": "未统计",
|
||||
"ntSub": "所显示族群中未找到新约的语言,按使用人数排列。红色:一无所有;黄色:有一些,但没有新约。",
|
||||
"ntTake": "这里有 {n} 种语言未找到新约,使用人数为 {people}。",
|
||||
"ntTitle": "哪些语言最需要新约?",
|
||||
"people": "人数",
|
||||
"peopleN": "{n} 人",
|
||||
"q1sub": "每个访问级别一条,针对所显示的族群。",
|
||||
"q1take": "所显示的 {total} 个族群中,有 {n} 个在母语中一无所有。",
|
||||
"q1title": "他们的母语中有什么?",
|
||||
"q2sub": "母语中一无所有的族群,按其国家的上网比例。",
|
||||
"q2take": "在 {n} 个一无所有的族群中,有 {online} 个生活在 60% 或以上人口使用互联网的国家。",
|
||||
"q2title": "互联网能把它带给一无所有的人吗?",
|
||||
"socialTop": "各国(当前范围)最主要的社交平台",
|
||||
"unitCountries": "个国家",
|
||||
"unitGroups": "个族群",
|
||||
|
|
@ -49,6 +61,18 @@
|
|||
"translatedBy": "译者",
|
||||
"wayIn": "进入途径"
|
||||
},
|
||||
"combos": {
|
||||
"audioOnline": "能听不能读,国家在线",
|
||||
"deaf": "有手语桥梁",
|
||||
"digitalNow": "母语一无所有,国家上网率 60% 以上",
|
||||
"indiaChinaGap": "印度和中国:母语一无所有",
|
||||
"none": "全部——选择一个筛选或组合",
|
||||
"offlineGap": "母语一无所有,国家大多离线",
|
||||
"selection": "您的选择",
|
||||
"telegram": "母语一无所有,Telegram 国家",
|
||||
"try": "试试组合",
|
||||
"whatsapp": "母语一无所有,WhatsApp 国家"
|
||||
},
|
||||
"detail": {
|
||||
"hear": "能看或能听,不能读",
|
||||
"none": "未找到",
|
||||
|
|
@ -107,6 +131,8 @@
|
|||
"count": "{total} 个族群中的 {n} 个",
|
||||
"empty": "没有符合这些筛选条件的族群。",
|
||||
"exported": "已导出 {n} 行(已复制并下载)。",
|
||||
"openCapped": "打开前 {n} 个所选项。",
|
||||
"openInGospel": "在福音资源中打开",
|
||||
"selectRow": "选择 {name}"
|
||||
},
|
||||
"headline": {
|
||||
|
|
@ -186,6 +212,14 @@
|
|||
"languagesHere": "种语言",
|
||||
"legendTitle": "以他们的母语",
|
||||
"levels": "语系 › 分支 › 语言 › 方言。语系或分支显示其内部最差的颜色。方言显示其语言的颜色(较浅):资源按语言编目,而不是按方言。点击圆圈即可将看板缩小到该范围。",
|
||||
"poster": "海报",
|
||||
"poster3ft": "3 × 3 英尺",
|
||||
"poster4ft": "4 × 4 英尺",
|
||||
"posterOmitted": "有 {n} 个名称因会与其他名称重叠而省略;每个圆圈都已绘出。",
|
||||
"posterPrint": "打印",
|
||||
"posterSizeAria": "海报尺寸",
|
||||
"posterSvg": "下载 SVG",
|
||||
"posterTitle": "福音资源,按语系逐一呈现",
|
||||
"sources": "语言树:Glottolog(CC BY 4.0)。资源:eBible、Faith Comes By Hearing、Scripture Earth、PNG.Bible、耶稣传影片、Global Recordings 等,按 ISO 639-3 编目。",
|
||||
"st": {
|
||||
"g": "已找到新约或整本圣经",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
},
|
||||
"upg100WallHeading": "我的 UPG 100 名單 — 禱告名單與海報",
|
||||
"upg100WallTag": "禱告動員",
|
||||
"upg100WallIntro": "將禱告的工作分成五十人、一百人一組 — 就像耶穌餵飽五千人那樣(路加福音 9:14)。每一張海報都是一份 100 人名單,你可以為他們禱告、列印出來、貼在牆上 — <strong>按著他們族群的名字</strong>為他們禱告。",
|
||||
"upg100WallIntroA": "將禱告的工作分成五十人、一百人一組 — 就像耶穌餵飽五千人那樣(路加福音 9:14)。每一張海報都是一份 100 人名單,你可以為他們禱告、列印出來、貼在牆上 — ",
|
||||
"upg100WallIntroEmph": "按著他們族群的名字",
|
||||
"upg100WallIntroB": "為他們禱告。",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"sendMap": "差派地图",
|
||||
"flowerMesh": "花网",
|
||||
"langCluster": "语言群",
|
||||
"flowers": "花朵"
|
||||
"flowers": "花朵",
|
||||
"mobilize": "动员",
|
||||
"campaigns": "宣传活动"
|
||||
},
|
||||
"hints": {
|
||||
"cockpit": "交叉筛选中心",
|
||||
|
|
@ -172,5 +174,80 @@
|
|||
"sm": {
|
||||
"counts": "{u} 个未得之民 · {s} 个已得差派方",
|
||||
"noSenders": "此目标没有具有已知国家中心点的合格已得差派方。"
|
||||
},
|
||||
"about": {
|
||||
"button": "使用说明",
|
||||
"title": "动员近邻",
|
||||
"close": "关闭",
|
||||
"why": "最适合接触未得之民的信徒,通常是在语言和文化上与他们最接近的人。本工具依据拉尔夫·温特(Ralph D. Winter)的 E 等级和 P 等级,为每个未得之民群体找出这些信徒,帮助你规划动员谁、用哪种语言、在哪里。",
|
||||
"eTitle": "E 等级:见证者需跨越的距离",
|
||||
"pTitle": "P 等级:一个民族与教会之间的距离",
|
||||
"dataTitle": "数据与局限",
|
||||
"data": "\"已得之民\"指福音派信徒至少占 2%(IMB 的定义)。语言和文化依据语言代码和 IMB 民族分类(族群、亲缘区块、语系)判断:是很好的公开近似指标,而非实地评估。信徒人数由人口和福音派比例区间估算,用于估计受众规模,不是精确统计。",
|
||||
"source": "民族数据:peoplegroups.org(IMB),people_groups.csv。E 等级与 P 等级:拉尔夫·温特,洛桑大会,1974 年。"
|
||||
},
|
||||
"e": {
|
||||
"short1": "E-1 · 同一语言",
|
||||
"short2": "E-2 · 相近文化",
|
||||
"short3": "E-3 · 邻近,不同文化",
|
||||
"title1": "E-1 · 说同一种语言",
|
||||
"title2": "E-2 · 相近的文化",
|
||||
"title3": "E-3 · 邻近但文化不同",
|
||||
"long1": "说这个群体语言的信徒。他们无需跨越语言障碍就能传福音,用这种语言开展的宣传活动可以动员他们。",
|
||||
"long2": "来自相关民族的信徒:同一民族群,或同一亲缘区块中的同一语系。比从零开始的文化跨度更短。",
|
||||
"long3": "住在附近(600 公里以内或同一国家)但文化不同的信徒。他们共享地区,而非语言。"
|
||||
},
|
||||
"p": {
|
||||
"title0": "未找到已得之民近邻",
|
||||
"title1": "有说其本族语言的信徒",
|
||||
"title2": "最近的信徒来自相近文化",
|
||||
"title3": "最近的信徒来自遥远文化",
|
||||
"long0": "在此数据中,没有已得之民群体在语言、文化或距离上与该民族相符。",
|
||||
"long1": "某处有已得之民群体说这个民族的语言。动员他们是最直接的途径。",
|
||||
"long2": "没有已得之民群体说这种语言,但相关民族中有信徒。",
|
||||
"long3": "附近只有不同文化的信徒。接触这个民族需要跨文化工人。"
|
||||
},
|
||||
"res": {
|
||||
"bible": "圣经",
|
||||
"film": "《耶稣传》电影",
|
||||
"audio": "音频",
|
||||
"radio": "广播"
|
||||
},
|
||||
"mob": {
|
||||
"legend": "地图图例",
|
||||
"legendUnreached": "未得之民群体",
|
||||
"searchPlaceholder": "搜索未得之民群体、国家或语言",
|
||||
"pFilter": "按 P 等级筛选",
|
||||
"pAll": "全部",
|
||||
"loading": "正在加载民族…",
|
||||
"intro": "选择一个未得之民群体(按规模排序),或点击地图上的红点,查看可以动员谁。",
|
||||
"noMatch": "没有匹配项。",
|
||||
"back": "所有群体",
|
||||
"people": "{n} 人",
|
||||
"believersNear": "下列近邻中约有 {n} 名信徒。",
|
||||
"resources": "其语言的资源",
|
||||
"export": "导出受众细分(CSV)",
|
||||
"openBridge": "策划一场 {lang} 宣传活动",
|
||||
"samePeople": "同一民族",
|
||||
"cardStats": "约 {b} 名信徒 · 相距 {km} 公里",
|
||||
"noNeighbors": "此数据中没有已得之民近邻。",
|
||||
"caveat": "根据 peoplegroups.org 数据估算。行动前请核实当地实际情况。",
|
||||
"loadError": "无法加载民族数据:{message}"
|
||||
},
|
||||
"camp": {
|
||||
"intro": "桥梁语言:信徒和未得之民都使用的语言。在信徒居住地用这种语言开展一场宣传活动,就能动员他们用对方的母语接触未得之民。",
|
||||
"searchPlaceholder": "搜索语言或国家",
|
||||
"minBelievers": "至少",
|
||||
"exportAll": "导出列表(CSV)",
|
||||
"unreachedPeople": "{n} 未得之民",
|
||||
"believers": "约 {n} 名信徒",
|
||||
"believersIn": "信徒所在:{list}",
|
||||
"back": "所有语言",
|
||||
"bigBelievers": "名可动员的信徒({g} 个群体,{c} 个国家)",
|
||||
"bigUnreached": "名待接触的未得之民({g} 个群体,{c} 个国家)",
|
||||
"export": "导出此活动(CSV)",
|
||||
"whereToRun": "在哪里开展:各国信徒",
|
||||
"believersInGroups": "约 {n},分布于 {g} 个群体",
|
||||
"whoTheyReach": "他们可以接触的人群"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "多视图",
|
||||
"cap2": "预设",
|
||||
"cap1": "单一",
|
||||
"stageExperimental": "实验性",
|
||||
"toggleList": "显示或隐藏列表",
|
||||
"openViews": "打开视图列表",
|
||||
"expandList": "展开列表",
|
||||
|
|
|
|||
|
|
@ -74,6 +74,14 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
|
|||
const SCALE = (PAGE_W_IN * 96 / W).toFixed(6); // print scale: one sheet = one page
|
||||
const M = 60, HEAD = 90; // margins + running header
|
||||
const COLS = 2, ROWS = 10, PER_PAGE = COLS * ROWS;
|
||||
// ★ THE THREE PRAYER COLUMNS, the Driver's own three questions (2026-09-27), in his order:
|
||||
// 1 have I blessed them
|
||||
// 2 God, what do you want me to KNOW about them
|
||||
// 3 God, what do you want me to DO to help them
|
||||
// A tick box per group per question, printed on the card so the packet is a WORKING sheet rather
|
||||
// than a reference: the reader marks it as they pray. Deliberately three and not more — the brief
|
||||
// is one hour with God, and a fourth column turns a prayer aid into a form.
|
||||
const PRAY_COLS = ['pray_blessed', 'pray_know', 'pray_do'];
|
||||
const GUT = 14;
|
||||
const CW = (W - 2 * M - GUT * (COLS - 1)) / COLS;
|
||||
const CH = (H - 2 * M - HEAD - GUT * (ROWS - 1)) / ROWS;
|
||||
|
|
@ -117,6 +125,41 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
|
|||
page-break-after:always;break-after:page}
|
||||
.sheet:last-child{page-break-after:auto;break-after:auto}
|
||||
.hd{position:absolute;left:${M}px;top:46px;font:700 30px Georgia,'Palatino Linotype',serif;color:#1b1a17}
|
||||
|
||||
/* ── LIFE CALENDAR (pages 2 and 3) ─────────────────────────────────────
|
||||
5,200 cells must fit one sheet and still read as countable boxes. 52 columns
|
||||
is fixed by the DATA (weeks in a year), so the cell size is derived from the
|
||||
printable width rather than chosen: (1500 - 2*60) / 52 = 26.5px per column,
|
||||
leaving a 21px box with a 2px gap. Measured, not eyeballed — a hardcoded box
|
||||
size that happened to fit at one margin would break the moment M changed. */
|
||||
.pg-head{margin:0 0 10px}
|
||||
.pg-kicker{font:700 15px Georgia,serif;letter-spacing:.14em;text-transform:uppercase;color:#8a8577}
|
||||
.pg-h1{margin:2px 0 0;font:700 40px Georgia,serif;color:#1b1a17}
|
||||
.pg-lede{margin:0 0 22px;max-width:1180px;font:400 19px/1.5 Georgia,serif;color:#413d35}
|
||||
/* ⚠ THE CELLS ARE RECTANGLES, NOT SQUARES, and that is forced by the sheet rather than chosen.
|
||||
52 columns is fixed by the data (weeks in a year) and 100 rows by the Driver's "100 year life",
|
||||
so both dimensions are given and only the SHAPE is free. Squares would need
|
||||
(1380/52)=26.5px per side x 100 rows = 2,654px of grid in 1,521px of printable height — it
|
||||
overflows the page by 1.7x. Caught by computing the height before rendering rather than by
|
||||
looking at an overflowing PDF. 13px rows put 100 rows in 1,300px with room for the legend. */
|
||||
.lc-grid{display:grid;grid-template-columns:repeat(52,1fr);grid-auto-rows:13px;gap:2px;width:100%}
|
||||
.lc-c{display:block;width:100%;height:100%;border:1px solid #d8d3c4;border-radius:1px;background:#fff}
|
||||
.lc-on{background:#b9b2a0;border-color:#b9b2a0}
|
||||
/* The shaded share on page 3 is the REACHED share, so it takes the accent colour and the
|
||||
unshaded majority stays quiet — the visual weight belongs to what is still waiting. */
|
||||
.lc-reached{background:#2f6d4f;border-color:#2f6d4f}
|
||||
.lc-legend{display:flex;gap:34px;margin:20px 0 0;font:400 17px Georgia,serif;color:#413d35}
|
||||
.lc-legend i{display:inline-block;width:15px;height:15px;vertical-align:-2px;margin-inline-end:8px}
|
||||
.lc-foot{margin:14px 0 0;font:400 14px Georgia,serif;color:#6f6a5c}
|
||||
|
||||
/* ── THE THREE PRAYER COLUMNS on every group card ───────────────────────
|
||||
Right-aligned inside the 683x160 card, which leaves ~412px for the name and
|
||||
numbers (measured from CW/CH rather than assumed). inset-inline-end, not right,
|
||||
so the column stack mirrors under dir=rtl with the rest of the card. */
|
||||
.pray-cols{position:absolute;inset-inline-end:10px;top:12px;display:flex;flex-direction:column;gap:9px}
|
||||
.pray-col{display:flex;align-items:center;gap:7px;font:400 11px Georgia,serif;color:#6f6a5c;white-space:nowrap}
|
||||
.pray-box{display:inline-block;width:17px;height:17px;border:1.5px solid #b0a993;border-radius:3px;background:#fff;flex:0 0 auto}
|
||||
.pray-col em{font-style:normal}
|
||||
.hd-r{position:absolute;right:${M}px;top:54px;font:400 19px Georgia,serif;color:#8c8170}
|
||||
.card{position:absolute;display:flex;gap:12px;align-items:center;
|
||||
border-bottom:1px solid #e2ddd0;padding-bottom:6px}
|
||||
|
|
@ -411,24 +454,131 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
|
|||
// FOOTNOTE rather than as the conclusion of the "43" row. The gap belongs BETWEEN rows; the share
|
||||
// is that last row's conclusion and should sit close under it. Proximity is the only thing
|
||||
// grouping them, so an even gap everywhere groups the share with the wrong element.
|
||||
return `<div style="margin-bottom:${r.mine ? 14 : GAP}px"${r.mine ? ' class="funnel-mine-row"' : ''}>
|
||||
// ★ CENTRED, so the bars form an UPSIDE-DOWN TRIANGLE narrowing to a point — the Driver's
|
||||
// "upside down triangle ... the bottom of the triangle shows the tip". margin-inline:auto on
|
||||
// the bar and text-align:center on the row is the whole geometry: each bar is already drawn
|
||||
// proportional to the first step, so centring them IS the triangle. No new maths, and the
|
||||
// careful population/grouping logic below is untouched.
|
||||
return `<div style="margin-bottom:${r.mine ? 14 : GAP}px;text-align:center"${r.mine ? ' class="funnel-mine-row"' : ''}>
|
||||
<div${r.mine ? ' class="funnel-mine"' : ''} style="font:700 ${r.mine ? 46 : 40}px Georgia,serif;color:${r.tint ? '#7a3b2e' : '#1b1a17'};line-height:1">${num(r.groups)}</div>
|
||||
<div style="font:400 ${r.mine ? 22 : 20}px Georgia,serif;color:#3b3226;margin-top:3px">${esc(r.label)}</div>
|
||||
${pop}
|
||||
<div style="height:16px;width:${width(r.groups)}px;background:${r.tint ? '#7a3b2e' : '#cdbf9e'};margin-top:8px;border-radius:2px"></div>
|
||||
<div style="height:16px;width:${width(r.groups)}px;background:${r.tint ? '#7a3b2e' : '#cdbf9e'};margin-top:8px;border-radius:2px;margin-inline:auto"></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// ── THE EQUATION SIDEBAR ────────────────────────────────────────────────
|
||||
// Driver, 2026-09-27: "a map equation with a side bar for the equations". Every number on this
|
||||
// page is DERIVED, and the derivation is the argument — a reader who cannot see how 12,379
|
||||
// becomes 2,107 has to take the funnel on faith. So each step states the filter that produced it,
|
||||
// read from vision-funnel.json's own step ids rather than written out here, and the provenance
|
||||
// line names the file and capture date.
|
||||
// ⚠ NOT A RESTATEMENT OF THE BARS. The bars show HOW MANY; the sidebar shows WHY THAT MANY. A
|
||||
// sidebar repeating the counts would add width and no information.
|
||||
const SIDEBAR_W = 300;
|
||||
const eqRows = allRows.filter((r) => !r.mine).map((r, idx, arr) => {
|
||||
const prev = idx > 0 ? arr[idx - 1] : null;
|
||||
const delta = prev ? Number(prev.groups) - Number(r.groups) : null;
|
||||
return `<div style="margin-bottom:16px">
|
||||
<div style="font:700 16px Georgia,serif;color:#3b3226">${num(r.groups)}</div>
|
||||
<div style="font:400 12px/1.45 Georgia,serif;color:#6f6a5c">${esc(r.equation || r.label)}</div>
|
||||
${delta !== null ? `<div style="font:400 11px Georgia,serif;color:#a89f8c;margin-top:2px">\u2212 ${num(delta)}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
const sidebar = `<div class="funnel-eq" style="position:absolute;top:${ROWS_TOP}px;inset-inline-end:${M}px;width:${SIDEBAR_W}px">
|
||||
<div style="font:700 13px Georgia,serif;letter-spacing:.1em;text-transform:uppercase;color:#8a8577;margin-bottom:12px">${esc(t('funnel_eq_title'))}</div>
|
||||
${eqRows}</div>`;
|
||||
|
||||
// ★ THE TIP OF THE TRIANGLE: this packet is one of N. The Driver's "the tip ... the smaller UPG
|
||||
// 100 list is a subset, 1 of 30 or n exact UPG 100 lists". N is READ from the catalogue
|
||||
// (funnel.listCount), never typed — it is 28 today and was 27 before the deaf zone became
|
||||
// reachable, so a literal would already be wrong.
|
||||
const tip = !funnel.listCount ? '' : `<div class="funnel-tip" style="text-align:center;margin-top:18px">
|
||||
<div style="font:700 15px Georgia,serif;letter-spacing:.08em;text-transform:uppercase;color:#7a3b2e">${esc(fill(t('funnel_tip'), { n: num(funnel.listCount) }))}</div>
|
||||
</div>`;
|
||||
|
||||
return sheet(`
|
||||
<div style="position:absolute;left:${M}px;top:44px;right:${M}px">
|
||||
<div class="funnel-title" style="font:700 40px Georgia,serif;color:#1b1a17">${esc(t('funnel_title'))}</div>
|
||||
<div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('funnel_intro'))}</div>
|
||||
</div>
|
||||
<div style="position:absolute;left:${M}px;top:${ROWS_TOP}px;right:${M}px">${rows}${share}</div>
|
||||
<div style="position:absolute;left:${M}px;top:${ROWS_TOP}px;right:${SIDEBAR_W + M + 24}px">${rows}${share}${tip}</div>
|
||||
${sidebar}
|
||||
<div class="fine">${foot}</div>`);
|
||||
}
|
||||
|
||||
// ── PAGES 3..n: the people groups, 20 per sheet ─────────────────────────────
|
||||
// ── LIFE CALENDAR: 100 years x 52 weeks = 5200 boxes ────────────────────────
|
||||
//
|
||||
// Driver, 2026-09-27: page 2 is "a life calendar of the years of a human life in weeks", and page 3
|
||||
// is "the same life cal used for percentage of Christian on average for dx people groups". ONE grid,
|
||||
// TWO readings — which is the whole point. A percentage drawn as a bar is an abstraction a reader
|
||||
// has to translate; drawn on the grid they have just understood as their OWN LIFE, it is the same
|
||||
// 5200 boxes they were looking at a page earlier.
|
||||
//
|
||||
// ★ 5,200 IS NOT ROUNDED OR CHOSEN FOR LAYOUT: 100 years x 52 weeks, the Driver's own numbers
|
||||
// ("a 100 year life in weeks"). Everything else here is derived from it, so the two pages cannot
|
||||
// drift apart into two grids that merely look alike.
|
||||
function lifeGrid({ shaded = 0, shadeClass = 'lc-on' } = {}) {
|
||||
const YEARS = 100, WEEKS = 52, TOTAL = YEARS * WEEKS;
|
||||
const n = Math.max(0, Math.min(TOTAL, Math.round(Number(shaded) || 0)));
|
||||
// Row-major, so the shaded run reads as a contiguous block from the top — a scattered
|
||||
// shading of the same COUNT would be honest arithmetic and an unreadable picture.
|
||||
const cells = [];
|
||||
for (let i = 0; i < TOTAL; i++) {
|
||||
cells.push(`<i class="lc-c${i < n ? ' ' + shadeClass : ''}"></i>`);
|
||||
}
|
||||
return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `<div class="lc-grid">${cells.join('')}</div>` };
|
||||
}
|
||||
|
||||
// Page 2 — a human life, unshaded except for the weeks already lived if the caller supplies an age.
|
||||
// No age is supplied today: the packet is printed for a reader we do not know, so shading their life
|
||||
// would be a guess presented as a fact. The empty grid is the honest version and still lands the
|
||||
// point — 5,200 boxes is a whole life on one page.
|
||||
function lifePage(zone) {
|
||||
const g = lifeGrid();
|
||||
return sheet(`
|
||||
<div class="pg-head"><div class="pg-kicker">${esc(t('life_kicker'))}</div>
|
||||
<h1 class="pg-h1">${esc(t('life_title'))}</h1></div>
|
||||
<p class="pg-lede">${esc(t('life_lede'))}</p>
|
||||
${g.html}
|
||||
<p class="lc-foot">${esc(t('life_foot'))}</p>
|
||||
`, 'sheet-life');
|
||||
}
|
||||
|
||||
// Page 3 — the SAME grid, shaded by the share of the DX population that is evangelical.
|
||||
//
|
||||
// ⚠ THE NUMBER IS POPULATION-WEIGHTED, NOT A GROUP COUNT, because the Driver asked for
|
||||
// "% of Christian population vs lost". Those differ sharply: 69.1% of the 2,091 matched DX GROUPS
|
||||
// have no known evangelicals, but weighted by POPULATION the evangelical share is 0.61% of
|
||||
// 209,079,159 people — 32 boxes of 5,200. A group-count version would have shown a different,
|
||||
// milder picture of the same data.
|
||||
// ⚠ AND THE BAND MIDPOINT IS AN ASSUMPTION, NAMED ON THE PAGE. peoplegroups.org publishes
|
||||
// EvangelicalLevel as a BAND ("Less than 2%", "No Known Evangelicals", …), never a number, so a
|
||||
// percentage requires choosing a value inside each band. The builder uses the band's midpoint and
|
||||
// 0 for "No Known Evangelicals"; the footnote says so, because a computed figure whose method is
|
||||
// invisible reads as a measurement.
|
||||
function christianPage(zone, christian) {
|
||||
if (!christian || !Number.isFinite(Number(christian.pctEvangelical))) return '';
|
||||
const pct = Number(christian.pctEvangelical);
|
||||
const g = lifeGrid({ shaded: (pct / 100) * 5200, shadeClass: 'lc-reached' });
|
||||
const lostPct = 100 - pct;
|
||||
// ⚠ TWO DECIMALS ON BOTH SIDES, not rounded above 10. The reached share is 0.61% and rounding the
|
||||
// other side to "99%" loses the asymmetry that is the whole point: 99.39 and 0.61 sum to 100, while
|
||||
// 99 and 0.61 do not, and a reader checking the arithmetic finds the page wrong.
|
||||
const fmtPct = (x) => x.toFixed(2).replace(/\.?0+$/, '');
|
||||
return sheet(`
|
||||
<div class="pg-head"><div class="pg-kicker">${esc(t('christian_kicker'))}</div>
|
||||
<h1 class="pg-h1">${esc(t('christian_title'))}</h1></div>
|
||||
<p class="pg-lede">${esc(t('christian_lede'))}</p>
|
||||
${g.html}
|
||||
<div class="lc-legend">
|
||||
<span><i class="lc-c lc-reached"></i> ${esc(t('christian_reached'))} — ${esc(fmtPct(pct))}% · ${num(g.shadedCount)} ${esc(t('life_weeks'))}</span>
|
||||
<span><i class="lc-c"></i> ${esc(t('christian_lost'))} — ${esc(fmtPct(lostPct))}% · ${num(g.TOTAL - g.shadedCount)} ${esc(t('life_weeks'))}</span>
|
||||
</div>
|
||||
<p class="lc-foot">${esc(t('christian_foot'))}${christian.population ? ` · ${num(christian.population)} ${esc(t('christian_people'))}` : ''}</p>
|
||||
`, 'sheet-christian');
|
||||
}
|
||||
|
||||
function listPages(zone, groups, photos) {
|
||||
const pages = [];
|
||||
for (let start = 0; start < groups.length; start += PER_PAGE) {
|
||||
|
|
@ -447,7 +597,10 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
|
|||
<div class="nm">${i + 1}. ${esc(g.name)}</div>
|
||||
<div class="mt" style="margin-top:4px">${esc(flat(g.religion))}</div>
|
||||
<div class="mt">${num(g.population)} ${esc(t('people_suffix'))} · ${esc(flat(g.country_code))}</div>
|
||||
</div></div>`;
|
||||
</div>
|
||||
<div class="pray-cols">${PRAY_COLS.map((k) => `<span class="pray-col"
|
||||
><i class="pray-box"></i><em>${esc(t(k))}</em></span>`).join('')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
pages.push(sheet(`
|
||||
<div class="hd">${esc(zone.label || zone.id)}</div>
|
||||
|
|
@ -551,9 +704,20 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
|
|||
// working unchanged and a host with no funnel data still gets a complete, correct packet. A page
|
||||
// that cannot state true numbers must not appear at all — better a 4-section packet than one
|
||||
// asserting figures it could not read.
|
||||
function buildPacket(zone, groups, photos, mapSvg, funnel = null) {
|
||||
function buildPacket(zone, groups, photos, mapSvg, funnel = null, christian = null) {
|
||||
// ★ PAGE ORDER IS THE DRIVER'S, 2026-09-27, and it is an argument rather than a layout:
|
||||
// 1 map — where this zone is
|
||||
// 2 life calendar — 100 years x 52 weeks, a whole human life on one page
|
||||
// 3 the SAME grid — shaded by the evangelical share of these groups' POPULATION
|
||||
// 4 funnel — 12,379 -> 7,341 -> 4,754 -> 2,107 -> this list, 1 of N
|
||||
// 5..n people groups with photos and the three prayer columns
|
||||
// then the 12 prayer points and the prayer wheel
|
||||
// Pages 2 and 3 are deliberately adjacent: the reader learns to read 5,200 boxes as their own
|
||||
// life, and then sees the same 5,200 boxes answer "how many of these people know Christ".
|
||||
const pages = [
|
||||
mapPage(zone, groups, mapSvg),
|
||||
lifePage(zone),
|
||||
christianPage(zone, christian),
|
||||
funnelPage(zone, groups, funnel),
|
||||
...listPages(zone, groups, photos),
|
||||
pointsPage(),
|
||||
|
|
@ -569,5 +733,5 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
|
|||
<body>${pages.join('\n')}</body></html>`;
|
||||
}
|
||||
|
||||
return { buildPacket, mapPage, funnelPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H };
|
||||
return { buildPacket, mapPage, lifePage, christianPage, funnelPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,7 +184,24 @@
|
|||
"funnel_list_frontier-500k": "Project 700: frontier people groups of 500,000 or more",
|
||||
"funnel_list_unengaged": "Unengaged unreached people groups",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} more"
|
||||
"zone_join_more": "{a} + {n} more",
|
||||
"life_kicker": "Perspective",
|
||||
"life_title": "A human life, in weeks",
|
||||
"life_lede": "One hundred years. Fifty-two weeks each. Five thousand two hundred boxes — and that is the whole of it. Every week you will ever pray is one of these.",
|
||||
"life_foot": "100 years x 52 weeks = 5,200 weeks.",
|
||||
"life_weeks": "weeks",
|
||||
"christian_kicker": "The same 5,200 boxes",
|
||||
"christian_title": "Who among them knows Christ",
|
||||
"christian_lede": "This is the same grid — one human life in weeks. Now each box is a share of the people in this list. The shaded boxes are the evangelical share of their population; the rest are waiting.",
|
||||
"christian_reached": "Evangelical",
|
||||
"christian_lost": "Still waiting",
|
||||
"christian_people": "people",
|
||||
"christian_foot": "Population-weighted from peoplegroups.org EvangelicalLevel, taking each published band at its midpoint and 0 for \"No Known Evangelicals\"",
|
||||
"pray_blessed": "Blessed them",
|
||||
"pray_know": "What to know",
|
||||
"pray_do": "What to do",
|
||||
"funnel_eq_title": "How each number is reached",
|
||||
"funnel_tip": "This packet is 1 of {n} UPG-100 lists"
|
||||
},
|
||||
"es": {
|
||||
"map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS",
|
||||
|
|
@ -361,7 +378,26 @@
|
|||
"funnel_list_frontier-500k": "Proyecto 700: grupos étnicos de frontera de 500.000 o más",
|
||||
"funnel_list_unengaged": "Grupos étnicos no alcanzados y sin obreros",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} más"
|
||||
"zone_join_more": "{a} + {n} más",
|
||||
"funnel_eq_title": "Cómo se obtiene cada número",
|
||||
"funnel_tip": "Este cuadernillo es 1 de {n} listas UPG-100",
|
||||
"life_kicker": "Perspectiva",
|
||||
"life_title": "Una vida humana, en semanas",
|
||||
"life_lede": "Cien años. Cincuenta y dos semanas cada uno. Cinco mil doscientas casillas — y eso es todo. Cada semana en que ores será una de estas.",
|
||||
"life_foot": "100 años x 52 semanas = 5.200 semanas.",
|
||||
"life_weeks": "semanas",
|
||||
"christian_kicker": "Las mismas 5.200 casillas",
|
||||
"christian_title": "Quiénes entre ellos conocen a Cristo",
|
||||
"christian_lede": "Es la misma cuadrícula — una vida humana en semanas. Ahora cada casilla es una parte de las personas de esta lista. Las casillas sombreadas son la proporción evangélica de su población; el resto espera.",
|
||||
"christian_reached": "Evangélicos",
|
||||
"christian_lost": "Aún esperando",
|
||||
"christian_people": "personas",
|
||||
"christian_foot": "Ponderado por población a partir del EvangelicalLevel de peoplegroups.org, tomando cada banda publicada en su punto medio y 0 para «No Known Evangelicals»",
|
||||
"pray_blessed": "Los bendije",
|
||||
"pray_know": "Qué saber",
|
||||
"pray_do": "Qué hacer",
|
||||
"point4_passage": "Y VINIERON de la otra parte de la mar á la provincia de los Gadarenos. Y salido él del barco, luego le salió al encuentro, de los sepulcros, un hombre con un espíritu inmundo, Que tenía domicilio en los sepulcros, y ni aun con cadenas le podía alguien atar; Porque muchas veces había sido atado con grillos y cadenas, mas las cadenas habían sido hechas pedazos por él, y los grillos desmenuzados; y nadie le podía domar. Y siempre, de día y de noche, andaba dando voces en los montes y en los sepulcros, é hiriéndose con las piedras. Y como vió á Jesús de lejos, corrió, y le adoró. Y clamando á gran voz, dijo: ¿Qué tienes conmigo, Jesús, Hijo del Dios Altísimo? Te conjuro por Dios que no me atormentes. Porque le decía: Sal de este hombre, espíritu inmundo. Y le preguntó: ¿Cómo te llamas? Y respondió diciendo: Legión me llamo; porque somos muchos. Y le rogaba mucho que no le enviase fuera de aquella provincia. Y estaba allí cerca del monte una grande manada de puercos paciendo. Y le rogaron todos los demonios, diciendo: Envíanos á los puercos para que entremos en ellos. Y luego Jesús se lo permitió. Y saliendo aquellos espíritus inmundos, entraron en los puercos, y la manada cayó por un despeñadero en la mar; los cuales eran como dos mil; y en la mar se ahogaron. Y los que apacentaban los puercos huyeron, y dieron aviso en la ciudad y en los campos. Y salieron para ver qué era aquello que había acontecido. Y vienen á Jesús, y ven al que había sido atormentado del demonio, y que había tenido la legión, sentado y vestido, y en su juicio cabal; y tuvieron miedo. Y les contaron los que lo habían visto, cómo había acontecido al que había tenido el demonio, y lo de los puercos. Y comenzaron á rogarle que se fuese de los términos de ellos. Y entrando él en el barco, le rogaba el que había sido fatigado del demonio, para estar con él. Mas Jesús no le permitió, sino le dijo: Vete á tu casa, á los tuyos, y cuéntales cuán grandes cosas el Señor ha hecho contigo, y cómo ha tenido misericordia de ti. Y se fué, y comenzó á publicar en Decápolis cuán grandes cosas Jesús había hecho con él: y todos se maravillaban.",
|
||||
"point4_passage_credit": "Marcos 5:1–20 · Reina Valera 1909 (dominio público) · ebible.org"
|
||||
},
|
||||
"fr": {
|
||||
"map_page_kicker": "GROUPES ETHNIQUES NON ATTEINTS",
|
||||
|
|
@ -538,7 +574,26 @@
|
|||
"funnel_list_frontier-500k": "Projet 700 : peuples frontières de 500 000 personnes ou plus",
|
||||
"funnel_list_unengaged": "Peuples non atteints et sans ouvrier",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} autres"
|
||||
"zone_join_more": "{a} + {n} autres",
|
||||
"funnel_eq_title": "Comment chaque nombre est obtenu",
|
||||
"funnel_tip": "Ce livret est 1 sur {n} listes UPG-100",
|
||||
"life_kicker": "Mise en perspective",
|
||||
"life_title": "Une vie humaine, en semaines",
|
||||
"life_lede": "Cent ans. Cinquante-deux semaines chacune. Cinq mille deux cents cases — et c’est tout. Chaque semaine où vous prierez sera l’une d’elles.",
|
||||
"life_foot": "100 ans x 52 semaines = 5 200 semaines.",
|
||||
"life_weeks": "semaines",
|
||||
"christian_kicker": "Les mêmes 5 200 cases",
|
||||
"christian_title": "Qui parmi eux connaît le Christ",
|
||||
"christian_lede": "C’est la même grille — une vie humaine en semaines. Chaque case représente maintenant une part des personnes de cette liste. Les cases colorées sont la part évangélique de leur population ; le reste attend.",
|
||||
"christian_reached": "Évangéliques",
|
||||
"christian_lost": "Toujours en attente",
|
||||
"christian_people": "personnes",
|
||||
"christian_foot": "Pondéré par la population à partir du EvangelicalLevel de peoplegroups.org, chaque tranche publiée étant prise à son point médian et 0 pour « No Known Evangelicals »",
|
||||
"pray_blessed": "Je les ai bénis",
|
||||
"pray_know": "Ce qu’il faut savoir",
|
||||
"pray_do": "Ce qu’il faut faire",
|
||||
"point4_passage": "Ils arrivèrent de l'autre côté de la mer, dans la contrée des Gadaréniens. Et aussitôt que Jésus fut descendu de la barque, un homme, possédé d'un esprit immonde, sortit des sépulcres et vint au-devant de lui. Il faisait sa demeure dans les sépulcres, et personne ne pouvait le tenir lié, pas même avec des chaînes; Car souvent, ayant eu les fers aux pieds, et ayant été lié de chaînes, il avait rompu les chaînes et brisé les fers; et personne ne le pouvait dompter. Et il demeurait continuellement, nuit et jour, sur les montagnes et dans les sépulcres, criant et se meurtrissant avec des pierres. Quand il eut vu Jésus de loin, il accourut et se prosterna devant lui, Et il dit, criant à haute voix: Qu'y a-t-il entre toi et moi, Jésus, Fils du Dieu très-haut? Je te conjure par le nom de Dieu de ne point me tourmenter. Car Jésus lui disait: Esprit immonde, sors de cet homme. Et Jésus lui demanda: Comment t'appelles-tu? Et il répondit: Je m'appelle Légion; car nous sommes plusieurs. Et il le priait instamment de ne pas les envoyer hors de cette contrée. Or, il y avait là, vers les montagnes, un grand troupeau de pourceaux qui paissait. Et tous les démons le priaient en disant: Envoie-nous dans ces pourceaux, afin que nous y entrions. Et aussitôt Jésus le leur permit. Alors ces esprits immondes étant sortis, entrèrent dans les pourceaux, et le troupeau se précipita avec impétuosité dans la mer, et ils se noyèrent dans la mer; or il y en avait environ deux mille. Et ceux qui paissaient les pourceaux s'enfuirent, et en portèrent la nouvelle dans la ville et par la campagne. Alors le peuple sortit pour voir ce qui était arrivé; et ils vinrent vers Jésus et virent le démoniaque, celui qui avait été possédé de la légion, assis, habillé et dans son bon sens; et ils furent remplis de crainte. Et ceux qui avaient vu cela, leur racontèrent ce qui était arrivé au démoniaque et aux pourceaux. Alors ils se mirent à le prier de se retirer de leurs quartiers. Et quand il fut entré dans la barque, celui qui avait été possédé le pria de lui permettre d'être avec lui. Mais Jésus ne le lui permit pas, et il dit: Va-t'en dans ta maison vers tes parents, et raconte-leur les grandes choses que le Seigneur t'a faites, et comment il a eu pitié de toi. Et il s'en alla, et se mit à publier dans la Décapole les grandes choses que Jésus lui avait faites; et ils étaient tous dans l'admiration.",
|
||||
"point4_passage_credit": "Marc 5:1-20 · French Ostervald Bible (domaine public) · ebible.org"
|
||||
},
|
||||
"pt": {
|
||||
"map_page_kicker": "GRUPOS ÉTNICOS NÃO ALCANÇADOS",
|
||||
|
|
@ -715,7 +770,24 @@
|
|||
"funnel_list_frontier-500k": "Projeto 700: grupos étnicos de fronteira de 500.000 ou mais",
|
||||
"funnel_list_unengaged": "Grupos étnicos não alcançados e sem obreiros",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} mais"
|
||||
"zone_join_more": "{a} + {n} mais",
|
||||
"funnel_eq_title": "Como cada número é obtido",
|
||||
"funnel_tip": "Este caderno é 1 de {n} listas UPG-100",
|
||||
"life_kicker": "Perspectiva",
|
||||
"life_title": "Uma vida humana, em semanas",
|
||||
"life_lede": "Cem anos. Cinquenta e duas semanas cada. Cinco mil e duzentas casas — e é tudo. Cada semana em que você orar será uma destas.",
|
||||
"life_foot": "100 anos x 52 semanas = 5.200 semanas.",
|
||||
"life_weeks": "semanas",
|
||||
"christian_kicker": "As mesmas 5.200 casas",
|
||||
"christian_title": "Quem entre eles conhece a Cristo",
|
||||
"christian_lede": "É a mesma grade — uma vida humana em semanas. Agora cada casa é uma parte das pessoas desta lista. As casas sombreadas são a proporção evangélica da sua população; o resto espera.",
|
||||
"christian_reached": "Evangélicos",
|
||||
"christian_lost": "Ainda esperando",
|
||||
"christian_people": "pessoas",
|
||||
"christian_foot": "Ponderado por população a partir do EvangelicalLevel de peoplegroups.org, tomando cada faixa publicada no seu ponto médio e 0 para «No Known Evangelicals»",
|
||||
"pray_blessed": "Abençoei-os",
|
||||
"pray_know": "O que saber",
|
||||
"pray_do": "O que fazer"
|
||||
},
|
||||
"de": {
|
||||
"map_page_kicker": "UNERREICHTE VOLKSGRUPPEN",
|
||||
|
|
@ -892,7 +964,26 @@
|
|||
"funnel_list_frontier-500k": "Projekt 700: Frontier-Volksgruppen mit 500.000 oder mehr",
|
||||
"funnel_list_unengaged": "Unerreichte Volksgruppen ohne Mitarbeiter",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} weitere"
|
||||
"zone_join_more": "{a} + {n} weitere",
|
||||
"funnel_eq_title": "Wie jede Zahl zustande kommt",
|
||||
"funnel_tip": "Dieses Heft ist 1 von {n} UPG-100-Listen",
|
||||
"life_kicker": "Maßstab",
|
||||
"life_title": "Ein Menschenleben, in Wochen",
|
||||
"life_lede": "Hundert Jahre. Je zweiundfünfzig Wochen. Fünftausendzweihundert Kästchen — und das ist alles. Jede Woche, in der Sie beten, ist eines davon.",
|
||||
"life_foot": "100 Jahre x 52 Wochen = 5.200 Wochen.",
|
||||
"life_weeks": "Wochen",
|
||||
"christian_kicker": "Dieselben 5.200 Kästchen",
|
||||
"christian_title": "Wer unter ihnen Christus kennt",
|
||||
"christian_lede": "Es ist dasselbe Raster — ein Menschenleben in Wochen. Jetzt steht jedes Kästchen für einen Anteil der Menschen auf dieser Liste. Die gefüllten Kästchen sind der evangelikale Anteil ihrer Bevölkerung; der Rest wartet.",
|
||||
"christian_reached": "Evangelikal",
|
||||
"christian_lost": "Warten noch",
|
||||
"christian_people": "Menschen",
|
||||
"christian_foot": "Bevölkerungsgewichtet aus dem EvangelicalLevel von peoplegroups.org, jede veröffentlichte Spanne in ihrer Mitte und 0 für „No Known Evangelicals“",
|
||||
"pray_blessed": "Gesegnet",
|
||||
"pray_know": "Was wissen",
|
||||
"pray_do": "Was tun",
|
||||
"point4_passage": "Und sie kamen jenseits des Meers in die Gegend der Gadarener. Und als er aus dem Schiff trat, lief ihm alsbald entgegen aus den Gräbern ein besessener Mensch mit einem unsaubern Geist, der seine Wohnung in den Gräbern hatte; und niemand konnte ihn binden, auch nicht mit Ketten. Denn er war oft mit Fesseln und Ketten gebunden gewesen, und hatte die Ketten abgerissen und die Fesseln zerrieben; und niemand konnte ihn zähmen. Und er war allezeit, Tag und Nacht, auf den Bergen und in den Gräbern, schrie und schlug sich mit Steinen. Da er aber Jesum sah von ferne, lief er zu und fiel vor ihm nieder, schrie laut und sprach: Was habe ich mit dir zu tun, o Jesu, du Sohn Gottes, des Allerhöchsten? Ich beschwöre dich bei Gott, daß du mich nicht quälest! Denn er sprach zu ihm: Fahre aus, du unsauberer Geist, von dem Menschen! Und er fragte ihn: Wie heißt du? Und er antwortete und sprach: Legion heiße ich; denn wir sind unser viele. Und er bat ihn sehr, daß er sie nicht aus der Gegend triebe. Und es war daselbst an den Bergen eine große Herde Säue auf der Weide. Und die Teufel baten ihn alle und sprachen: Laß uns in die Säue fahren! Und alsbald erlaubte es ihnen Jesus. Da fuhren die unsauberen Geister aus und fuhren in die Säue; und die Herde stürzte sich von dem Abhang ins Meer (ihrer waren aber bei zweitausend) und ersoffen im Meer. Und die Sauhirten flohen und verkündigten das in der Stadt und auf dem Lande. Und sie gingen hinaus, zu sehen, was da geschehen war, und kamen zu Jesu und sahen den, der von den Teufeln besessen war, daß er saß und war bekleidet und vernünftig, und fürchteten sich. Und die es gesehen hatten, sagten ihnen, was dem Besessenen widerfahren war, und von den Säuen. Und sie fingen an und baten ihn, daß er aus ihrer Gegend zöge. Und da er in das Schiff trat, bat ihn der Besessene, daß er möchte bei ihm sein. Aber Jesus ließ es nicht zu, sondern sprach zu ihm: Gehe hin in dein Haus und zu den Deinen und verkündige ihnen, wie große Wohltat dir der HERR getan und sich deiner erbarmt hat. Und er ging hin und fing an, auszurufen in den zehn Städten, wie große Wohltat ihm Jesus getan hatte; und jedermann verwunderte sich.",
|
||||
"point4_passage_credit": "Markus 5,1–20 · German Luther Bible 1912 (gemeinfrei) · ebible.org"
|
||||
},
|
||||
"it": {
|
||||
"map_page_kicker": "GRUPPI ETNICI NON RAGGIUNTI",
|
||||
|
|
@ -1069,7 +1160,24 @@
|
|||
"funnel_list_frontier-500k": "Progetto 700: gruppi etnici di frontiera di 500.000 o più",
|
||||
"funnel_list_unengaged": "Gruppi etnici non raggiunti e senza operai",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + altri {n}"
|
||||
"zone_join_more": "{a} + altri {n}",
|
||||
"funnel_eq_title": "Come si ottiene ogni numero",
|
||||
"funnel_tip": "Questo fascicolo è 1 di {n} liste UPG-100",
|
||||
"life_kicker": "Prospettiva",
|
||||
"life_title": "Una vita umana, in settimane",
|
||||
"life_lede": "Cento anni. Cinquantadue settimane ciascuno. Cinquemiladuecento caselle — ed è tutto. Ogni settimana in cui pregherai sarà una di queste.",
|
||||
"life_foot": "100 anni x 52 settimane = 5.200 settimane.",
|
||||
"life_weeks": "settimane",
|
||||
"christian_kicker": "Le stesse 5.200 caselle",
|
||||
"christian_title": "Chi tra loro conosce Cristo",
|
||||
"christian_lede": "È la stessa griglia — una vita umana in settimane. Ora ogni casella è una parte delle persone di questa lista. Le caselle colorate sono la quota evangelica della loro popolazione; il resto attende.",
|
||||
"christian_reached": "Evangelici",
|
||||
"christian_lost": "Ancora in attesa",
|
||||
"christian_people": "persone",
|
||||
"christian_foot": "Ponderato per popolazione dall’EvangelicalLevel di peoplegroups.org, prendendo ogni fascia pubblicata al suo punto medio e 0 per «No Known Evangelicals»",
|
||||
"pray_blessed": "Li ho benedetti",
|
||||
"pray_know": "Cosa sapere",
|
||||
"pray_do": "Cosa fare"
|
||||
},
|
||||
"ro": {
|
||||
"map_page_kicker": "GRUPURI ETNICE NEATINSE",
|
||||
|
|
@ -1246,7 +1354,24 @@
|
|||
"funnel_list_frontier-500k": "Proiectul 700: grupuri etnice de frontieră de 500.000 sau mai multe",
|
||||
"funnel_list_unengaged": "Grupuri etnice neatinse și fără lucrători",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + încă {n}"
|
||||
"zone_join_more": "{a} + încă {n}",
|
||||
"funnel_eq_title": "Cum se obține fiecare număr",
|
||||
"funnel_tip": "Acest caiet este 1 din {n} liste UPG-100",
|
||||
"life_kicker": "Perspectivă",
|
||||
"life_title": "O viață de om, în săptămâni",
|
||||
"life_lede": "O sută de ani. Câte cincizeci și două de săptămâni. Cinci mii două sute de căsuțe — și asta e tot. Fiecare săptămână în care te vei ruga este una dintre ele.",
|
||||
"life_foot": "100 de ani x 52 de săptămâni = 5.200 de săptămâni.",
|
||||
"life_weeks": "săptămâni",
|
||||
"christian_kicker": "Aceleași 5.200 de căsuțe",
|
||||
"christian_title": "Cine dintre ei Îl cunoaște pe Hristos",
|
||||
"christian_lede": "Este aceeași grilă — o viață de om în săptămâni. Acum fiecare căsuță este o parte dintre oamenii de pe această listă. Căsuțele colorate sunt partea evanghelică a populației lor; restul așteaptă.",
|
||||
"christian_reached": "Evanghelici",
|
||||
"christian_lost": "Încă așteaptă",
|
||||
"christian_people": "persoane",
|
||||
"christian_foot": "Ponderat după populație pornind de la EvangelicalLevel de pe peoplegroups.org, fiecare interval publicat luat la mijloc și 0 pentru „No Known Evangelicals”",
|
||||
"pray_blessed": "I-am binecuvântat",
|
||||
"pray_know": "Ce să știu",
|
||||
"pray_do": "Ce să fac"
|
||||
},
|
||||
"ru": {
|
||||
"map_page_kicker": "НЕОХВАЧЕННЫЕ НАРОДЫ",
|
||||
|
|
@ -1423,7 +1548,26 @@
|
|||
"funnel_list_frontier-500k": "Проект 700: народы-фронтир численностью 500 000 и более",
|
||||
"funnel_list_unengaged": "Неохваченные народы без работников",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + ещё {n}"
|
||||
"zone_join_more": "{a} + ещё {n}",
|
||||
"funnel_eq_title": "Как получено каждое число",
|
||||
"funnel_tip": "Этот буклет — 1 из {n} списков UPG-100",
|
||||
"life_kicker": "Масштаб",
|
||||
"life_title": "Человеческая жизнь в неделях",
|
||||
"life_lede": "Сто лет. По пятьдесят две недели. Пять тысяч двести клеток — и это всё. Каждая неделя, когда вы молитесь, — одна из них.",
|
||||
"life_foot": "100 лет x 52 недели = 5200 недель.",
|
||||
"life_weeks": "недель",
|
||||
"christian_kicker": "Те же 5200 клеток",
|
||||
"christian_title": "Кто среди них знает Христа",
|
||||
"christian_lede": "Это та же сетка — человеческая жизнь в неделях. Теперь каждая клетка — доля людей из этого списка. Закрашенные клетки — евангельская доля их населения; остальные ждут.",
|
||||
"christian_reached": "Евангельские",
|
||||
"christian_lost": "Всё ещё ждут",
|
||||
"christian_people": "человек",
|
||||
"christian_foot": "Взвешено по населению на основе EvangelicalLevel с peoplegroups.org: каждый опубликованный диапазон берётся по середине, «No Known Evangelicals» — как 0",
|
||||
"pray_blessed": "Благословил",
|
||||
"pray_know": "Что узнать",
|
||||
"pray_do": "Что сделать",
|
||||
"point4_passage": "И пришли на другой берег моря, в страну Гадаринскую. И когда вышел Он из лодки, тотчас встретил Его вышедший из гробов человек, одержимый нечистым духом, он имел жилище в гробах, и никто не мог его связать даже цепями, потому что многократно был он скован оковами и цепями, но разрывал цепи и разбивал оковы, и никто не в силах был укротить его; всегда, ночью и днем, в горах и гробах, кричал он и бился о камни; увидев же Иисуса издалека, прибежал и поклонился Ему, и, вскричав громким голосом, сказал: что Тебе до меня, Иисус, Сын Бога Всевышнего? заклинаю Тебя Богом, не мучь меня! Ибо Иисус сказал ему: выйди, дух нечистый, из сего человека. И спросил его: как тебе имя? И он сказал в ответ: легион имя мне, потому что нас много. И много просили Его, чтобы не высылал их вон из страны той. Паслось же там при горе большое стадо свиней. И просили Его все бесы, говоря: пошли нас в свиней, чтобы нам войти в них. Иисус тотчас позволил им. И нечистые духи, выйдя, вошли в свиней; и устремилось стадо с крутизны в море, а их было около двух тысяч; и потонули в море. Пасущие же свиней побежали и рассказали в городе и в деревнях. И жители вышли посмотреть, что случилось. Приходят к Иисусу и видят, что бесновавшийся, в котором был легион, сидит и одет, и в здравом уме; и устрашились. Видевшие рассказали им о том, как это произошло с бесноватым, и о свиньях. И начали просить Его, чтобы отошел от пределов их. И когда Он вошел в лодку, бесновавшийся просил Его, чтобы быть с Ним. Но Иисус не дозволил ему, а сказал: иди домой к своим и расскажи им, что сотворил с тобою Господь и как помиловал тебя. И пошел и начал проповедывать в Десятиградии, что сотворил с ним Иисус; и все дивились.",
|
||||
"point4_passage_credit": "Марка 5:1–20 · Russian Synodal Bible (общественное достояние) · ebible.org"
|
||||
},
|
||||
"ar": {
|
||||
"map_page_kicker": "شعوب غير مبلَّغة",
|
||||
|
|
@ -1600,7 +1744,26 @@
|
|||
"funnel_list_frontier-500k": "مشروع 700: المجموعات الشعبية الحدودية التي يبلغ عددها 500,000 أو أكثر",
|
||||
"funnel_list_unengaged": "مجموعات شعبية غير مبلَّغة وبلا عاملين",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} أخرى"
|
||||
"zone_join_more": "{a} + {n} أخرى",
|
||||
"funnel_eq_title": "كيف نصل إلى كل رقم",
|
||||
"funnel_tip": "هذه الحزمة واحدة من {n} قائمة UPG-100",
|
||||
"life_kicker": "منظور",
|
||||
"life_title": "حياة إنسان، بالأسابيع",
|
||||
"life_lede": "مئة عام. اثنان وخمسون أسبوعًا في كل عام. خمسة آلاف ومئتا مربّع — وهذا كل شيء. كل أسبوع تصلّي فيه هو واحد منها.",
|
||||
"life_foot": "١٠٠ سنة × ٥٢ أسبوعًا = ٥٢٠٠ أسبوع.",
|
||||
"life_weeks": "أسبوعًا",
|
||||
"christian_kicker": "المربّعات الـ٥٢٠٠ نفسها",
|
||||
"christian_title": "مَن منهم يعرف المسيح",
|
||||
"christian_lede": "إنها الشبكة نفسها — حياة إنسان بالأسابيع. الآن كل مربّع يمثّل نصيبًا من الناس في هذه القائمة. المربّعات الملوّنة هي النسبة الإنجيلية من سكانهم؛ والبقية ينتظرون.",
|
||||
"christian_reached": "إنجيليون",
|
||||
"christian_lost": "ما زالوا ينتظرون",
|
||||
"christian_people": "نسمة",
|
||||
"christian_foot": "مرجّح بالسكان من EvangelicalLevel على peoplegroups.org، مع أخذ كل نطاق منشور عند نقطة منتصفه و٠ لـ«No Known Evangelicals»",
|
||||
"pray_blessed": "باركتهم",
|
||||
"pray_know": "ماذا أعرف",
|
||||
"pray_do": "ماذا أفعل",
|
||||
"point4_passage": "وَجَاءُوا إِلَى عَبْرِ ٱلْبَحْرِ إِلَى كُورَةِ ٱلْجَدَرِيِّينَ. وَلَمَّا خَرَجَ مِنَ ٱلسَّفِينَةِ لِلْوَقْتِ ٱسْتَقْبَلَهُ مِنَ ٱلْقُبُورِ إِنْسَانٌ بِهِ رُوحٌ نَجِسٌ، كَانَ مَسْكَنُهُ فِي ٱلْقُبُورِ، وَلَمْ يَقْدِرْ أَحَدٌ أَنْ يَرْبِطَهُ وَلَا بِسَلَاسِلَ، لِأَنَّهُ قَدْ رُبِطَ كَثِيرًا بِقُيُودٍ وَسَلَاسِلَ فَقَطَّعَ ٱلسَّلَاسِلَ وَكَسَّرَ ٱلْقُيُودَ، فَلَمْ يَقْدِرْ أَحَدٌ أَنْ يُذَلِّلَهُ. وَكَانَ دَائِمًا لَيْلًا وَنَهَارًا فِي ٱلْجِبَالِ وَفِي ٱلْقُبُورِ، يَصِيحُ وَيُجَرِّحُ نَفْسَهُ بِٱلْحِجَارَةِ. فَلَمَّا رَأَى يَسُوعَ مِنْ بَعِيدٍ رَكَضَ وَسَجَدَ لَهُ، وَصَرَخَ بِصَوْتٍ عَظِيمٍ وَقَالَ: «مَا لِي وَلَكَ يَا يَسُوعُ ٱبْنَ ٱللهِ ٱلْعَلِيِّ؟ أَسْتَحْلِفُكَ بِٱللهِ أَنْ لَا تُعَذِّبَنِي!». لِأَنَّهُ قَالَ لَهُ: «ٱخْرُجْ مِنَ ٱلْإِنْسَانِ يَا أَيُّهَا ٱلرُّوحُ ٱلنَّجِسُ». وَسَأَلَهُ: «مَا ٱسْمُكَ؟». فَأَجَابَ قَائِلًا: «ٱسْمِي لَجِئُونُ، لِأَنَّنَا كَثِيرُونَ». وَطَلَبَ إِلَيْهِ كَثِيرًا أَنْ لَا يُرْسِلَهُمْ إِلَى خَارِجِ ٱلْكُورَةِ. وَكَانَ هُنَاكَ عِنْدَ ٱلْجِبَالِ قَطِيعٌ كَبِيرٌ مِنَ ٱلْخَنَازِيرِ يَرْعَى، فَطَلَبَ إِلَيْهِ كُلُّ ٱلشَّيَاطِينِ قَائِلِينَ: «أَرْسِلْنَا إِلَى ٱلْخَنَازِيرِ لِنَدْخُلَ فِيهَا». فَأَذِنَ لَهُمْ يَسُوعُ لِلْوَقْتِ. فَخَرَجَتِ ٱلْأَرْوَاحُ ٱلنَّجِسَةُ وَدَخَلَتْ فِي ٱلْخَنَازِيرِ، فَٱنْدَفَعَ ٱلْقَطِيعُ مِنْ عَلَى ٱلْجُرْفِ إِلَى ٱلْبَحْرِ. وَكَانَ نَحْوَ أَلْفَيْنِ، فَٱخْتَنَقَ فِي ٱلْبَحْرِ. وَأَمَّا رُعَاةُ ٱلْخَنَازِيرِ فَهَرَبُوا وَأَخْبَرُوا فِي ٱلْمَدِينَةِ وَفِي ٱلضِّيَاعِ. فَخَرَجُوا لِيَرَوْا مَا جَرَى. وَجَاءُوا إِلَى يَسُوعَ فَنَظَرُوا ٱلْمَجْنُونَ ٱلَّذِي كَانَ فِيهِ ٱللَّجِئُونُ جَالِسًا وَلَابِسًا وَعَاقِلًا، فَخَافُوا. فَحَدَّثَهُمُ ٱلَّذِينَ رَأَوْا كَيْفَ جَرَى لِلْمَجْنُونِ وَعَنِ ٱلْخَنَازِيرِ. فَٱبْتَدَأُوا يَطْلُبُونَ إِلَيْهِ أَنْ يَمْضِيَ مِنْ تُخُومِهِمْ. وَلَمَّا دَخَلَ ٱلسَّفِينَةَ طَلَبَ إِلَيْهِ ٱلَّذِي كَانَ مَجْنُونًا أَنْ يَكُونَ مَعَهُ، فَلَمْ يَدَعْهُ يَسُوعُ، بَلْ قَالَ لَهُ: «ٱذْهَبْ إِلَى بَيْتِكَ وَإِلَى أَهْلِكَ، وَأَخْبِرْهُمْ كَمْ صَنَعَ ٱلرَّبُّ بِكَ وَرَحِمَكَ». فَمَضَى وَٱبْتَدَأَ يُنَادِي فِي ٱلْعَشْرِ ٱلْمُدُنِ كَمْ صَنَعَ بِهِ يَسُوعُ. فَتَعَجَّبَ ٱلْجَمِيعُ.",
|
||||
"point4_passage_credit": "مرقس ٥:١–٢٠ · Arabic Van Dyck Bible (ملك عام) · ebible.org"
|
||||
},
|
||||
"hi": {
|
||||
"map_page_kicker": "अनछुए जनसमूह",
|
||||
|
|
@ -1777,7 +1940,24 @@
|
|||
"funnel_list_frontier-500k": "परियोजना 700: 500,000 या अधिक के सीमांत जनसमूह",
|
||||
"funnel_list_unengaged": "अप्राप्त जनसमूह जिनमें कोई कार्यकर्ता नहीं",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} और"
|
||||
"zone_join_more": "{a} + {n} और",
|
||||
"funnel_eq_title": "हर संख्या कैसे प्राप्त होती है",
|
||||
"funnel_tip": "यह पुस्तिका {n} UPG-100 सूचियों में से 1 है",
|
||||
"life_kicker": "परिप्रेक्ष्य",
|
||||
"life_title": "एक मानव जीवन, सप्ताहों में",
|
||||
"life_lede": "सौ वर्ष। हर वर्ष बावन सप्ताह। पाँच हज़ार दो सौ खाने — और बस इतना ही। जिस भी सप्ताह आप प्रार्थना करेंगे, वह इन्हीं में से एक है।",
|
||||
"life_foot": "100 वर्ष x 52 सप्ताह = 5,200 सप्ताह।",
|
||||
"life_weeks": "सप्ताह",
|
||||
"christian_kicker": "वही 5,200 खाने",
|
||||
"christian_title": "उनमें से कौन मसीह को जानता है",
|
||||
"christian_lede": "यह वही ग्रिड है — सप्ताहों में एक मानव जीवन। अब हर खाना इस सूची के लोगों का एक हिस्सा है। रंगे हुए खाने उनकी जनसंख्या का सुसमाचारी हिस्सा हैं; बाकी प्रतीक्षा कर रहे हैं।",
|
||||
"christian_reached": "सुसमाचारी",
|
||||
"christian_lost": "अब भी प्रतीक्षा में",
|
||||
"christian_people": "लोग",
|
||||
"christian_foot": "peoplegroups.org के EvangelicalLevel से जनसंख्या-भारित, हर प्रकाशित श्रेणी को उसके मध्यबिंदु पर और «No Known Evangelicals» को 0 मानते हुए",
|
||||
"pray_blessed": "आशीष दी",
|
||||
"pray_know": "क्या जानना है",
|
||||
"pray_do": "क्या करना है"
|
||||
},
|
||||
"zh": {
|
||||
"map_page_kicker": "未得之民",
|
||||
|
|
@ -1954,6 +2134,23 @@
|
|||
"funnel_list_frontier-500k": "700 计划:50 万人以上的前沿族群",
|
||||
"funnel_list_unengaged": "无人服事的未得之民族群",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + 另外 {n} 个"
|
||||
"zone_join_more": "{a} + 另外 {n} 个",
|
||||
"funnel_eq_title": "每个数字是如何得出的",
|
||||
"funnel_tip": "本手册是 {n} 份 UPG-100 名单中的 1 份",
|
||||
"life_kicker": "视角",
|
||||
"life_title": "一生,以周计",
|
||||
"life_lede": "一百年。每年五十二周。五千二百个格子——就这些。你祷告的每一周都是其中之一。",
|
||||
"life_foot": "100 年 x 52 周 = 5,200 周。",
|
||||
"life_weeks": "周",
|
||||
"christian_kicker": "同样的 5,200 个格子",
|
||||
"christian_title": "他们中谁认识基督",
|
||||
"christian_lede": "这是同一张网格——以周计的一生。现在每个格子代表这份名单中人口的一部分。着色的格子是他们人口中的福音派比例;其余的仍在等待。",
|
||||
"christian_reached": "福音派",
|
||||
"christian_lost": "仍在等待",
|
||||
"christian_people": "人",
|
||||
"christian_foot": "依据 peoplegroups.org 的 EvangelicalLevel 按人口加权,每个已公布区间取其中点,\"No Known Evangelicals\" 计为 0",
|
||||
"pray_blessed": "已祝福",
|
||||
"pray_know": "当知道什么",
|
||||
"pray_do": "当做什么"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,17 @@ const { mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H }
|
|||
// ── DATA ────────────────────────────────────────────────────────────────────
|
||||
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
|
||||
|
||||
// ── THE CHRISTIAN-SHARE PAGE (packet page 3) ────────────────────────────────
|
||||
// READ, NEVER COMPUTED HERE. data/people-groups/christian-share.json is generated from the IMB CSV
|
||||
// joined to DOXA's snapshot on ROP3+country — the same join the Vision Card uses — and it carries its
|
||||
// own _method and provenance. Re-deriving the percentage in this file would give one fact two sources
|
||||
// that drift apart, which is the mistake the funnel page already documents for its four steps.
|
||||
// A missing file yields null and buildPacket omits page 3 rather than printing a number it could not read.
|
||||
let CHRISTIAN = null;
|
||||
try {
|
||||
CHRISTIAN = JSON.parse(readFileSync(join(__dirname, '../../../data/people-groups/christian-share.json'), 'utf8'));
|
||||
} catch (_) { /* page 3 is omitted, not faked */ }
|
||||
|
||||
// ── THE PAGE-2 FUNNEL ───────────────────────────────────────────────────────
|
||||
// The same context the browser path builds in upg-100-list.vue's packetFunnel(), so the Node
|
||||
// generator and the in-page print cannot disagree about page 2 any more than they can about the
|
||||
|
|
@ -153,10 +164,15 @@ function buildFunnel(membership) {
|
|||
// resolve, and clusters.json's two DEAF lists are a second, overlapping partition that
|
||||
// allLists() excludes. "In no list" has no single answer until someone says which partition is
|
||||
// meant. Both paths must agree, so this one stops claiming it too.
|
||||
const lists = [...(clusters.lists || []), ...(clusters.asia_lists || []),
|
||||
...(clusters.uncovered_lists || [])];
|
||||
// ⚠ COUNT THROUGH leafZones, NOT THE THREE CLUSTER KEYS. Spreading lists/asia_lists/
|
||||
// uncovered_lists gives 27 and silently omits `deaf`, whose two deaf_lists entries
|
||||
// buildLegendNodes collapses into ONE unified leaf — legend-tree.js:106 states the arithmetic
|
||||
// ("13 + 12 + 2 + 1 deaf = 28"). The packet's triangle tip prints this number as "1 of N", so a
|
||||
// 27 there is a visible wrong fact on the page. Same omission as allZones() had before 3ef164c0;
|
||||
// this was the last copy of it.
|
||||
const listCount = allZones().length;
|
||||
const listTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
|
||||
return { ...raw, listCount: lists.length, listTotal };
|
||||
return { ...raw, listCount, listTotal };
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
|
||||
|
|
@ -289,7 +305,7 @@ async function fetchPhotos(groups, limit = 16) {
|
|||
// the shared renderer, so the two packets cannot drift.
|
||||
async function buildPacket(zone, groups, mapSvg) {
|
||||
const photos = await fetchPhotos(groups);
|
||||
const html = R.buildPacket(zone, groups, photos, mapSvg, FUNNEL);
|
||||
const html = R.buildPacket(zone, groups, photos, mapSvg, FUNNEL, CHRISTIAN);
|
||||
const withPhoto = groups.filter((g) => photos.has(g.slug)).length;
|
||||
// ★ COUNT THE SHEETS IN THE EMITTED HTML, do not re-derive them from a formula. The old line
|
||||
// computed `1 + ceil(n/PER_PAGE) + 2` while its own comment claimed the number was "asked of the
|
||||
|
|
|
|||
31
library/utils/formatCount.js
Normal file
31
library/utils/formatCount.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* formatCount — the ONE place the fleet decides how a COUNT is punctuated.
|
||||
*
|
||||
* ★ IT HOLDS en-US ON PURPOSE. Whether numerals follow the reader's locale is a DRIVER decision and
|
||||
* it is currently held, so this file changes nothing about what anyone sees: every caller renders
|
||||
* exactly the digits and separators it rendered before. What it changes is the number of places that
|
||||
* decision is written down — from thirteen call sites across four bundles to one.
|
||||
*
|
||||
* That is the whole value while the policy is held: if the answer becomes "follow the locale", this
|
||||
* is a one-line edit here (read the same hostLang -> getRuntimeLocale() -> detectLocale() chain the
|
||||
* text reads) instead of thirteen edits by four owners, each free to pick a different locale source.
|
||||
* The first divergence in that world would be invisible: two panels in one dashboard, same number,
|
||||
* different separators.
|
||||
*
|
||||
* ★ IT FORMATS. IT DOES NOT DECIDE WHAT EMPTY MEANS, and that distinction is not fastidiousness —
|
||||
* it is the bug this fold would otherwise have shipped. The three `fmt` helpers it replaced looked
|
||||
* interchangeable and were not:
|
||||
* DashboardDataTable null/undefined -> '' and '' falls through to Number('') === 0, so "0"
|
||||
* DashboardDetailDrawer null/undefined/'' -> '—'
|
||||
* useDashboardShell null/undefined -> '—' and '' likewise becomes "0"
|
||||
* Three different answers to "what does a missing count look like", in one dashboard. Folding them
|
||||
* onto one empty policy would have silently changed two of the three — a table cell going from blank
|
||||
* to an em dash, or an em dash appearing where a zero used to. So each caller keeps its own guard and
|
||||
* hands this function a value it has already decided is real.
|
||||
*/
|
||||
export const COUNT_LOCALE = 'en-US'
|
||||
|
||||
/** @param {number|string} n a value the caller has already established is present */
|
||||
export const formatCount = (n) => Number(n).toLocaleString(COUNT_LOCALE)
|
||||
|
||||
export default formatCount
|
||||
|
|
@ -216,6 +216,7 @@
|
|||
"check:snapshot-guard": "node internal/check-snapshot-guard.mjs",
|
||||
"check:snapshot-guard:poison": "node internal/check-snapshot-guard.mjs --poison",
|
||||
"check:spa-tab-order": "node internal/check-spa-tab-order.mjs",
|
||||
"check:square-map-controls": "node internal/check-square-map-controls.mjs",
|
||||
"check:sri": "node internal/sri-consistency-check.mjs",
|
||||
"check:stale-sweep-claim": "node internal/check-no-stale-sweep-claim.mjs",
|
||||
"check:studio-all": "node internal/check-studio-every-plugin.mjs",
|
||||
|
|
@ -236,12 +237,14 @@
|
|||
"check:vision-card-numbers": "node internal/check-vision-card-numbers.mjs",
|
||||
"check:wp-embed": "php deployment/wordpress/tests/wp-embed-contract-test.php",
|
||||
"check:zone-census": "node internal/check-zone-census.mjs",
|
||||
"check:zoning-declared": "node internal/check-zoning-declared.mjs",
|
||||
"check:zoning-declared:self-test": "node internal/check-zoning-declared.mjs --self-test",
|
||||
"clean": "node internal/clean.mjs",
|
||||
"deploy:dev": "bun run build && node internal/sync-dropin.mjs --from-config --prune",
|
||||
"dev": "bun x vite",
|
||||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion",
|
||||
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
|
|
@ -425,6 +428,7 @@
|
|||
"check:link-coverage": "rule 4 of the brief — 'verify a URL resolves before shipping it' — as a gate instead of a habit. verify-gospel-links.mjs does the verifying; nothing asserted the record COVERS what is shipped, so the honest state of the corpus was a number someone had to go and measure. DISCOVERS the URLs by recursing the pin index for any http string rather than naming fields: every field list in this bundle has gone stale (ga and se shipped unscreened, the SUN block and the access table were each added after the fact), and the recursion found 16 links my own hand-rolled measurement had missed. Resolves each against THREE records — the verification table, translator-org-links.json (contacts, fetched by their own script) and sun-deaf-index.json (proved by content against a catch-all control, which is stronger than a status code) — and fails on any unrecorded link, any shipped link recorded BROKEN, and any 'unknown' on a target a status code CAN prove. The status-blind list holds one URL SHAPE, not a host: jesusfilm.org/watch/jesus/<slug> discriminates and 2,049 links depend on that, while /watch/?languageId= does not. Static, sub-second. Self-test: `bun run check:link-coverage:poison`.",
|
||||
"check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",
|
||||
"check:spa-tab-order": "the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",
|
||||
"check:square-map-controls": "MapToolbar controls=\"square\" must be the ENGINE'S grouped card, not a square of ours. Reads node_modules/maplibre-gl/dist/maplibre-gl.css and compares our ported values against THAT FILE -- 4px corners, 0 0 0 2px rgba(0,0,0,.1) ring, 1px solid #ddd dividers -- because the look the Driver asked for is the engine default (DX-life adds NavigationControl and overrides nothing), so the target is published rather than a matter of taste. Asserts the three deliberate deviations: the engine's 29px is NOT adopted (touch target over pixel parity), there is NO overflow:hidden (it clips a focus ring; outer corners come from first/last child rounding, the engine's own mechanism), and a forced-colors block ships. Also asserts the BLAST RADIUS: round is the default and none of the nine MapToolbar consumers passes controls=, so no map changes -- opting one in turns it red. CSS assertions read the text passed to useShadowStyles, not the file, because the prop's doc comment quotes the engine's 29px and a checker that cannot tell a comment from a declaration finds the documentation it stands on.",
|
||||
"check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.",
|
||||
"check:surfaces-read-the-tier": "a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.",
|
||||
"check:tabs-localized": "a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.",
|
||||
|
|
@ -434,6 +438,7 @@
|
|||
"check:url-resolver": "cap-5 resolve() must not prefix a rooted URL (the dev-surface 404 class)",
|
||||
"check:vision-card-numbers": "the DOXA Vision Card must show the numbers the DATA gives: independently recomputes all four funnel steps (and DOXA-in-frontier) from people_groups.csv + the saved DOXA list, compares with vision-funnel.json, and with --url also with the numbers painted in the card. A typed-in number, a stale funnel file or a rendering bug fails it.",
|
||||
"check:zone-census": "every zone a USER CAN SELECT must have a poster (asks the LEGEND, not clusters.json — two collapsed deaf lists are unreachable and not a gap). CONDITIONAL: SKIPs without out/posters/, which is gitignored — run `bun run posters` first. It cannot be fixtured like check:packets-all, because it asserts COVERAGE of real generated output and a fixture would only assert the fixture is complete.",
|
||||
"check:zoning-declared": "A project list's ZONING matches what its author DECLARED (data/zoning-declarations.json). Zoning is human judgement — geography first, balanced with language — so a gate cannot decide whether a zoning is GOOD; it can decide whether it still matches what was stated, which is what makes UPG-100 reusable as a PATTERN for any project list (7000/4700/3000/2000/700) zoned by an agent who did not write this code. ★ EVERY OBVIOUS RULE FAILED THE REFERENCE IMPLEMENTATION when measured on the live 28 zones / 2107 records: 'every group in exactly one zone' is wrong (112 double-claims, 110 of them the design-approved deaf bloc double-count, legend-tree.js:151) and 'no group unzoned' is wrong (11 JPN/CHN/MNG groups, plausibly deliberate). So it asserts A1 no zone resolves to zero groups, A2 at most one GEOGRAPHIC zone per group with bloc zones declared as a second partition, A3 the orphan set EQUALS the declared set (a RESOLVED orphan fails too — a stale declaration stops being evidence), A4 every selector type is one selector-evaluator supports, A5 no zone over the declared page budget. A1 and A4 would both have caught this cycle's real defects (deaf + 5 wagf zones resolving to nothing) before a browser did, and A2 caught a third overlap my own hand analysis had missed. Exit 75 without records or declarations. Run :self-test to prove the five still fire.",
|
||||
"deploy:dev": "destination + allowlist come from vite.doxa-maps-build-config.json (dropin.dest / dropin.include); DOXA_DROPIN_DEST=<dir> overrides the destination",
|
||||
"gallery": "writes public/__gallery/index.html — the ARCHIVED bundle-index gallery, from public/doxa-maps/manifest.json. It is NO LONGER the root page: as of 2026-09-22 `/` mounts <plugin-studio> in BOTH trees (the built root page is written by `bun run tree`), and the gallery is the opt-in index at /__gallery. Archived, not deleted — the dev server imports renderGallery()/readBuildConfig() from this module.",
|
||||
"gen:iso3-to-alpha2": "derive library/data/iso3-to-alpha2.json from vendored CLDR (48.2.2, Unicode-3.0) joined against the 242 codes in library/data/countries.json, so Intl.DisplayNames can produce a LOCALIZED country name — it takes alpha-2 and our data speaks alpha-3. Offline, no dependency. GRADES ITS OWN OUTPUT and writes nothing on failure: 242 coverage, injectivity, and KOS -> XK as the one documented exception (Kosovo has no ISO assignment). Cross-checks the 176-country research spine map and REPORTS disagreements without rewriting it — that file drives fetches and is not this generator's to edit.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"stage": "production",
|
||||
"type": "single",
|
||||
"label": "Cities with Internet — connectivity map",
|
||||
"config": {
|
||||
|
|
|
|||
|
|
@ -127,9 +127,25 @@ function paneElement(pane) {
|
|||
return el
|
||||
}
|
||||
|
||||
// `inert` on every pane except the active one: the same contract as upg-100-mfe (4642e78d). The panes
|
||||
// are stacked in one scroll-snap column, so the hidden pane is still full-size geometry; inert tells
|
||||
// PosterWall (67e9ede5) the wall is out of view so it forgets its shown card, and a second
|
||||
// "Show on map" after Map -> back SHOWS the country again instead of toggling it off (rega row 79).
|
||||
// inert, not display:none: hiding would tear down the live map.
|
||||
function markActivePane(activeId) {
|
||||
if (!column || !activeId) return
|
||||
for (const pane of column.panes) {
|
||||
const el = column.mountEl(pane.id)
|
||||
if (!el) continue
|
||||
if (pane.id === activeId) el.removeAttribute('inert')
|
||||
else el.setAttribute('inert', '')
|
||||
}
|
||||
}
|
||||
|
||||
function go(id) {
|
||||
view.value = id
|
||||
column?.scrollTo(id)
|
||||
markActivePane(id) // now, not after the smooth scroll settles
|
||||
}
|
||||
|
||||
// ── tab 1 -> tab 2 (shell seam) ─────────────────────────────────────────────
|
||||
|
|
@ -183,11 +199,16 @@ onMounted(() => {
|
|||
column.mountEl(pane.id)?.appendChild(el)
|
||||
}
|
||||
// Tabs are a readout as well as a control — follow the user's scroll.
|
||||
markActivePane(column.current() || view.value) // the fresh-load state, before any switch
|
||||
offScroll = column.onScroll(() => {
|
||||
const cur = column.current()
|
||||
if (cur && cur !== view.value) view.value = cur
|
||||
if (cur) markActivePane(cur) // always: a switcher-driven scroll already set view.value
|
||||
})
|
||||
window.addEventListener(SHOW_ON_MAP, onShowOnMap)
|
||||
// profile-config `view` opens a chosen pane ("map" or "wall"), so an embed can start on the map.
|
||||
const opening = cfg.value?.view
|
||||
if (opening && opening !== firstPaneId.value && spec.value.panes.some((p) => p.id === opening)) go(opening)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"stage": "production",
|
||||
"_comment": "Capability-4 composite that BAKES the research-dashboard + people-groups-grid into ONE self-contained element (both components imported directly + compiled into a single bundle, NO runtime bundle injection — unlike stack-demo which composes via src). Rendered stacked via the shared StackLayout so you scroll through both. This is the 'two elements baked into one' element the Driver asked for.",
|
||||
"type": "nested-elements-mfe",
|
||||
"composes": ["research-dashboard", "people-groups-grid"],
|
||||
|
|
|
|||
|
|
@ -449,7 +449,9 @@ const HEAVY_ELEMENTS = new Set([
|
|||
'my-upg-100-list-map', 'doxa-map', 'doxa-maps-spa',
|
||||
// composites that NEST a map, so they own a context too (measured, see above)
|
||||
'dashboard-plus-grid', 'research-dashboard-map', 'demo-map', 'all-people-groups',
|
||||
'country-posters-map', 'key-cities-map', 'research-map-plus-grid'
|
||||
'country-posters-map', 'key-cities-map', 'research-map-plus-grid',
|
||||
// up to TWO contexts: its map, plus the language-tree whiteboard once that is opened
|
||||
'gospel-internet-board'
|
||||
])
|
||||
|
||||
// SCROLL-STACK ELEMENTS — the rows this shell mounts WITHOUT the fixed-viewport inline style
|
||||
|
|
@ -591,7 +593,7 @@ function experimentalTabsFromManifest(manifest) {
|
|||
const out = []
|
||||
for (const b of bundles) {
|
||||
const id = b && b.bundle
|
||||
if (!id || placed.has(id) || NOT_EXPERIMENTAL.has(id)) continue
|
||||
if (!id || placed.has(id) || NOT_EXPERIMENTAL.has(id) || b.stage === 'production') continue // stage: the bundle's .meta.json, same field the Plugin Studio reads
|
||||
// Pick the bundle's entry profile: prefer a real MAP profile, else the profile named like the
|
||||
// bundle, else simply the first one. A bundle contributes exactly ONE rail row (see dedupeRows),
|
||||
// so this chooses which of its profiles that row opens on.
|
||||
|
|
|
|||
4
plugin-designs/doxa-simple-map/doxa-simple-map.meta.json
Normal file
4
plugin-designs/doxa-simple-map/doxa-simple-map.meta.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"stage": "production",
|
||||
"_comment": "Prayer Progress is a production map: the Plugin Studio and the gallery list it outside Experimental (stage is read into manifest.json by generate-tree)."
|
||||
}
|
||||
|
|
@ -5,5 +5,7 @@
|
|||
"pinHint": false,
|
||||
"urlState": "query"
|
||||
},
|
||||
"note": "pinHint:false because the board has its own legend and drawer; the shared coach-mark would float over the facet rail. urlState:'query' makes the board's own page keep ?view=&access=&band=&country=&province=&language=&group= in the address bar (shareable views, SPEC-GOSPEL-INTERNET-DASHBOARD section 3). A partner embedding the element without it keeps their URL untouched: the board READS the query always and WRITES it only when asked."
|
||||
"note": "pinHint:false because the board has its own legend and drawer; the shared coach-mark would float over the facet rail. urlState:'query' makes the board's own page keep ?view=&access=&band=&country=&province=&language=&group= in the address bar (shareable views, SPEC-GOSPEL-INTERNET-DASHBOARD section 3). A partner embedding the element without it keeps their URL untouched: the board READS the query always and WRITES it only when asked.",
|
||||
"stage": "experimental",
|
||||
"heavy": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
* internet figures are the country's (or state's); "found in the sources checked", never "reached"; no
|
||||
* engagement, no one-works-here. Sources are named from the table's own meta.
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, getCurrentInstance } from 'vue'
|
||||
import { useDashboardLayout } from '@map/composables/useDashboardLayout.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { listLabel } from '@map/lists/peopleGroupList.js'
|
||||
|
|
@ -34,10 +34,34 @@ import GiGrid from './src/components/GiGrid.vue'
|
|||
import GiDrawer from './src/components/GiDrawer.vue'
|
||||
import GiTree from './src/components/GiTree.vue'
|
||||
import GiResources from './src/components/GiResources.vue'
|
||||
import GiCombos from './src/components/GiCombos.vue'
|
||||
|
||||
const T = useGiT()
|
||||
const board = useBoard()
|
||||
const { bus } = board
|
||||
// ── INTEROP OUT: every committed selection leaves as a DOM event another element or the host page can
|
||||
// take (SPEC-DASHBOARD-KIT 5.3, level 2): bubbling + composed, so it crosses the shadow boundary; CODES,
|
||||
// never labels (slugs, ISO 639-3, ISO 3166 alpha-3); debounced so a range drag sends one event. The host
|
||||
// is found through the instance captured HERE, in setup: getCurrentInstance() in a watcher is null, and
|
||||
// that is exactly how Simple Research's map -> grid event silently never fired.
|
||||
const selfInstance = getCurrentInstance()
|
||||
let selTimer = null
|
||||
watch(() => bus.selectionVersion.value, () => {
|
||||
if (selTimer) clearTimeout(selTimer)
|
||||
selTimer = setTimeout(() => {
|
||||
selTimer = null
|
||||
const host = selfInstance?.proxy?.$el?.getRootNode?.()?.host
|
||||
if (!host) return
|
||||
const slugs = [...bus.selectionSet]
|
||||
const rows = slugs.map((s) => board.bySlug.value.get(s)).filter(Boolean)
|
||||
host.dispatchEvent(new CustomEvent('gospel-internet:selection', { bubbles: true, composed: true, detail: {
|
||||
slugs, languages: [...new Set(rows.map((r) => r.iso639_3))], countries: [...new Set(rows.map((r) => r.iso3))],
|
||||
list: board.listId.value,
|
||||
} }))
|
||||
}, 150)
|
||||
})
|
||||
onBeforeUnmount(() => { if (selTimer) clearTimeout(selTimer) })
|
||||
|
||||
// The URL / host preset (view, facets, centre panel, group) is applied BEFORE the first render, so the
|
||||
// board opens already in its state: no flash of the default view, and the first render mounts the panel
|
||||
// the preset asks for (the language tree included) where a headless mount check can see it.
|
||||
|
|
@ -61,7 +85,7 @@ onBeforeUnmount(() => { try { ro && ro.disconnect() } catch (_) {} })
|
|||
// The kit's layout engine, under THIS board's key (landing 2's namespacing), grid mode.
|
||||
const { startFacetResize, startRailResize, startTableResize, onResizeKeydown, facetW, railW, tableH } = useDashboardLayout({
|
||||
shellRef: shell, bus, isNarrow, storageKey: 'gi-board-layout-v1',
|
||||
defaults: { facetW: 264, railW: 300, tableH: '38%' },
|
||||
defaults: { facetW: 264, railW: 360, tableH: '38%' },
|
||||
})
|
||||
|
||||
// ── the headline: the board's question answered in one line, for the list in view ───────────────────
|
||||
|
|
@ -123,9 +147,11 @@ const sourcesLine = computed(() => (board.meta.value.sources || []).join(' · ')
|
|||
useShadowStyles(
|
||||
':host{display:block}' +
|
||||
'.gb{--gb-bg:#f3f3f1;position:relative;box-sizing:border-box;width:100%;height:100%;min-height:640px;display:grid;background:var(--gb-bg);color:#3b463d;' +
|
||||
'grid-template-columns:var(--facet-w,264px) minmax(0,1fr) var(--rail-w,300px);' +
|
||||
'grid-template-rows:auto auto minmax(0,1fr) var(--table-h,38%);' +
|
||||
'grid-template-areas:"top top top" "note note note" "facets map rail" "facets grid grid";' +
|
||||
'grid-template-columns:var(--facet-w,264px) minmax(0,1fr) var(--rail-w,360px);' +
|
||||
'grid-template-rows:auto auto auto minmax(0,1fr) var(--table-h,38%);' +
|
||||
// The charts rail runs the full height beside the map AND the table: sized to the map row alone it was
|
||||
// ~300px and clipped its four charts (rega, the Driver: "the charts don't look good").
|
||||
'grid-template-areas:"top top top" "combos combos combos" "note note note" "facets map rail" "facets grid rail";' +
|
||||
'font:13px/1.35 Roboto,-apple-system,"Segoe UI",sans-serif;overflow:hidden}' +
|
||||
'.gb-top{grid-area:top;display:flex;align-items:center;flex-wrap:wrap;gap:10px 16px;padding:10px 14px;background:#fff;border-bottom:1px solid #d1d4cd}' +
|
||||
'.gb-title{margin:0;font-size:17px;font-weight:700;letter-spacing:.005em}' +
|
||||
|
|
@ -165,8 +191,8 @@ useShadowStyles(
|
|||
'.gb-rz-table::after{width:34px;height:3px}' +
|
||||
'.gb-live{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}' +
|
||||
// narrow: one panel at a time, a segmented control, no drag handles
|
||||
'.gb.narrow{grid-template-columns:minmax(0,1fr);grid-template-rows:auto auto auto minmax(0,1fr);' +
|
||||
'grid-template-areas:"top" "note" "mtabs" "panel"}' +
|
||||
'.gb.narrow{grid-template-columns:minmax(0,1fr);grid-template-rows:auto auto auto auto minmax(0,1fr);' +
|
||||
'grid-template-areas:"top" "combos" "note" "mtabs" "panel"}' +
|
||||
'.gb.narrow .gb-facets,.gb.narrow .gb-map,.gb.narrow .gb-rail,.gb.narrow .gb-grid{grid-area:panel;border:0}' +
|
||||
'.gb-mtabs{grid-area:mtabs;display:flex;gap:4px;padding:6px 10px;background:#fff;border-bottom:1px solid #d1d4cd}' +
|
||||
'.gb-mtab{flex:1;border:1px solid #d1d4cd;background:#fff;color:#3b463d;border-radius:6px;min-height:32px;font:inherit;font-size:12.5px;cursor:pointer}' +
|
||||
|
|
@ -196,6 +222,7 @@ useShadowStyles(
|
|||
<span v-if="listChip" class="gb-chip" :title="T('gospelInternet.listChip', 'People-group list, chosen by this page')">{{ listChip }}</span>
|
||||
<button v-if="scoped" type="button" class="gb-reset" @click="resetAll">{{ T('controls.reset_all', 'Reset all') }}</button>
|
||||
</header>
|
||||
<GiCombos />
|
||||
<p class="gb-note" data-notice="standing">
|
||||
{{ T('gospelInternet.notice.standing', 'Dots are approximate (where a people is found in its country, not a town). Internet figures are the country\'s, or the Indian state\'s, never the group\'s. "Found" means found in the sources checked.') }}
|
||||
<span v-if="sourcesLine"> {{ T('gospelInternet.notice.sources', 'Sources:') }} {{ sourcesLine }}.</span>
|
||||
|
|
|
|||
|
|
@ -98,8 +98,12 @@ function collapse(n) {
|
|||
roots.forEach(collapse)
|
||||
|
||||
// ── radial layout per family ────────────────────────────────────────────────────────────────────────
|
||||
const ARC = 1.0 // minimum arc between neighbouring leaves (layout units)
|
||||
const STEP_MIN = 3.2 // minimum ring spacing
|
||||
// Two layouts from one algorithm: the SCREEN one (ARC 1: the map engine hides labels that do not fit,
|
||||
// and reveals them as the reader zooms) and the POSTER one (wider leaf spacing, because a printed sheet
|
||||
// cannot zoom: every name must fit at one scale). The poster's is stored as `pc` / `pR` beside `c` / `R`.
|
||||
let ARC = 1.0 // minimum arc between neighbouring leaves (layout units)
|
||||
let STEP_MIN = 3.2 // minimum ring spacing
|
||||
const POSTER = { ARC: 1.0, STEP_MIN: 3.2 }
|
||||
function leaves(n) { return n._leaves ??= (n.kids.length ? n.kids.reduce((s, k) => s + leaves(k), 0) : 1) }
|
||||
function depth(n) { return n._depth ??= (n.kids.length ? 1 + Math.max(...n.kids.map(depth)) : 0) }
|
||||
function layoutFamily(root) {
|
||||
|
|
@ -120,27 +124,53 @@ function layoutFamily(root) {
|
|||
root._R = far + 1.6 + Math.min(3, Math.sqrt(nLeaves) * 0.25)
|
||||
return root
|
||||
}
|
||||
roots.forEach(layoutFamily)
|
||||
|
||||
// ── pack the families: largest first, nearest free spot on a spiral ────────────────────────────────
|
||||
const placed = []
|
||||
for (const root of [...roots].sort((a, b) => b._R - a._R || a.name.localeCompare(b.name))) {
|
||||
const R = root._R
|
||||
let best = null
|
||||
if (!placed.length) best = [0, 0]
|
||||
for (let t = 0; !best; t += 0.05) {
|
||||
const rad = 0.35 * t * Math.max(1, R * 0.2)
|
||||
const x = rad * Math.cos(t), y = rad * Math.sin(t)
|
||||
if (placed.every((p) => Math.hypot(p.x - x, p.y - y) >= p.R + R + 0.8)) best = [x, y]
|
||||
function pack() {
|
||||
const placed = []
|
||||
for (const root of [...roots].sort((a, b) => b._R - a._R || a.name.localeCompare(b.name))) {
|
||||
const R = root._R
|
||||
let best = null
|
||||
if (!placed.length) best = [0, 0]
|
||||
for (let t = 0; !best; t += 0.05) {
|
||||
const rad = 0.35 * t * Math.max(1, R * 0.2)
|
||||
const x = rad * Math.cos(t), y = rad * Math.sin(t)
|
||||
if (placed.every((p) => Math.hypot(p.x - x, p.y - y) >= p.R + R + 0.8)) best = [x, y]
|
||||
}
|
||||
placed.push({ x: best[0], y: best[1], R })
|
||||
root._cx = best[0]; root._cy = best[1]
|
||||
}
|
||||
placed.push({ x: best[0], y: best[1], R })
|
||||
root._cx = best[0]; root._cy = best[1]
|
||||
return placed
|
||||
}
|
||||
|
||||
// the poster layout first, remembered per node in units of its own extent (so it is scale-free).
|
||||
// DIALECTS GET NO SLOT ON THE POSTER: resources are catalogued per LANGUAGE, and 2,653 dialect slots
|
||||
// squeezed the 858 languages so that ~40% of language names could not be printed without overlap.
|
||||
const clearMemo = (n) => { delete n._leaves; delete n._depth; n.kids.forEach(clearMemo) }
|
||||
const hideDialects = (n) => { n._allKids = n.kids; n.kids = n.kids.filter((k) => k.level !== 'dialect'); n.kids.forEach(hideDialects) }
|
||||
const showDialects = (n) => { if (n._allKids) { n.kids = n._allKids; delete n._allKids } n.kids.forEach(showDialects) }
|
||||
roots.forEach(hideDialects); roots.forEach(clearMemo)
|
||||
;({ ARC, STEP_MIN } = POSTER)
|
||||
roots.forEach(layoutFamily)
|
||||
{
|
||||
const pl = pack()
|
||||
let pext = 1
|
||||
for (const p of pl) pext = Math.max(pext, Math.abs(p.x) + p.R, Math.abs(p.y) + p.R)
|
||||
const keep = (n, root) => { n._pu = [(root._cx + n._r * Math.cos(n._a)) / pext, (root._cy + n._r * Math.sin(n._a)) / pext]; n.kids.forEach((k) => keep(k, root)) }
|
||||
for (const root of roots) { keep(root, root); root._pR = root._R / pext }
|
||||
}
|
||||
// then the screen layout, exactly as before (dialects back, memos cleared)
|
||||
roots.forEach(showDialects); roots.forEach(clearMemo)
|
||||
;({ ARC, STEP_MIN } = { ARC: 1.0, STEP_MIN: 3.2 })
|
||||
roots.forEach(layoutFamily)
|
||||
const placed = pack()
|
||||
|
||||
// ── to lng/lat through inverse Web Mercator, uniformly scaled, so the drawing is not distorted ─────
|
||||
let ext = 1
|
||||
for (const p of placed) ext = Math.max(ext, Math.abs(p.x) + p.R, Math.abs(p.y) + p.R)
|
||||
const K = 0.34 / ext // the tapestry spans ~68% of the Mercator square
|
||||
// The tapestry spans ~34% of the Mercator square. The engines stop at zoom 0 (the whole square), so the
|
||||
// smaller the drawing, the further a reader can zoom OUT from its opening view: at 68% there was about
|
||||
// one level of room (Driver, 2026-09-27: "I need to be able to zoom out a bit more"); now there are two.
|
||||
const K = 0.17 / ext
|
||||
const toLngLat = (u, v) => {
|
||||
const X = 0.5 + u * K, Y = 0.5 - v * K
|
||||
const lng = (X - 0.5) * 360
|
||||
|
|
@ -159,7 +189,8 @@ function emit(n, root, parentId, isRoot) {
|
|||
if (n.via) node.via = n.via
|
||||
if (lv === 'l' || (lv === 'd' && n.iso && groupsBy.has(n.iso))) node.st = statusOf(n.iso)
|
||||
if (n.iso && groupsBy.has(n.iso)) node.g = groupsBy.get(n.iso)
|
||||
if (lv === 'f' || isRoot) node.R = Math.round(root._R * K * 1e6) / 1e6
|
||||
if (lv === 'f' || isRoot) { node.R = Math.round(root._R * K * 1e6) / 1e6; node.pR = Math.round(root._pR * 1e5) / 1e5 }
|
||||
if (n._pu) node.pc = [Math.round(n._pu[0] * 1e5) / 1e5, Math.round(n._pu[1] * 1e5) / 1e5] // dialects: none
|
||||
nodes.push(node)
|
||||
// Edges are NOT written: every node carries its parent id and both coordinates, so the board draws
|
||||
// each parent->child line itself (4,300 lines in one GeoJSON source). It halves the file.
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const get = (lc, key) => {
|
|||
// SETTLED, not deferred: the correct word in that language IS the English spelling. Each is a decision,
|
||||
// listed by locale and key so a new identical string is still caught.
|
||||
const SAME_ON_PURPOSE = new Set(['fr:res.source', 'fr:res.type', 'ro:tree.dialect', 'fr:facet.religion',
|
||||
'de:facet.region', 'de:facet.religion'])
|
||||
'de:facet.region', 'de:facet.religion', 'de:tree.poster', 'it:tree.poster', 'ro:tree.poster'])
|
||||
|
||||
let fails = 0
|
||||
if (keys.size < 100) { console.log(`NOT MEASURED: read only ${keys.size} keys (expected 100+); the parser is broken, not the bundle`); process.exitCode = 75 }
|
||||
|
|
|
|||
|
|
@ -82,6 +82,13 @@ const back = M.buildQuery({ view: 'zero', filterSet: { band: ['yes', 'partial'],
|
|||
ok(back.includes('lang=ar') && back.includes('list=700') && back.includes('band=yes%7Cpartial') && back.includes('view=zero'),
|
||||
`query builds and keeps lang/list: ${back}`)
|
||||
ok(M.parseQuery('?view=nonsense').view === 'board', 'an unknown view falls back to board')
|
||||
// embed-contract parity with Gospel Resources: ?groups= (peoples by slug) and ?codes= / ?code= (languages)
|
||||
const pg = M.parseQuery('?groups=abai-sungai,hadrami-arabs&codes=abf,ayh')
|
||||
ok(pg.groups.length === 2 && Array.isArray(pg.filters.language) && pg.filters.language.join('|') === 'abf|ayh', 'parses ?groups= (slugs) and ?codes= (languages), comma-separated as Gospel Resources writes them')
|
||||
ok(M.parseQuery('?code=ara').filters.language === 'ara' && M.parseQuery('?codes=ARAB,xx').filters.language === undefined, '?code= alone is a language; malformed codes are refused, never guessed')
|
||||
const link = M.gospelResourcesLink('https://x.test/dx-maps/gospel-resources/resources-map/index.html', ['a', 'b'], { list: '700', lang: 'ar' })
|
||||
ok(link.includes('groups=a%2Cb') && link.includes('list=700') && link.includes('lang=ar'), `out-link to Gospel Resources carries groups, list and lang: ${link.split('?')[1]}`)
|
||||
ok(new URLSearchParams(M.gospelResourcesLink('u', Array.from({ length: 500 }, (_, i) => 's' + i)).split('?')[1]).get('groups').split(',').length === M.GROUPS_LINK_CAP, 'the out-link is capped at GROUPS_LINK_CAP slugs')
|
||||
|
||||
// 7. CSV
|
||||
const csv = M.buildCsv(
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ try {
|
|||
const M = await load('src/model.js')
|
||||
const { useBoard } = await load('src/useBoard.js')
|
||||
const panels = {}
|
||||
for (const n of ['GiFacetRail', 'GiMap', 'GiCharts', 'GiGrid', 'GiDrawer', 'GiTree', 'GiResources']) panels[n] = (await load(`src/components/${n}.vue`)).default
|
||||
for (const n of ['GiCombos', 'GiFacetRail', 'GiMap', 'GiCharts', 'GiGrid', 'GiDrawer', 'GiTree', 'GiResources']) panels[n] = (await load(`src/components/${n}.vue`)).default
|
||||
|
||||
const gi = T('gospel-internet.json')
|
||||
const rows = POISON ? [] : M.normalizeRows(gi)
|
||||
|
|
@ -61,7 +61,7 @@ try {
|
|||
b.tree.value = markRaw({ ...M.indexTree(treeDoc), meta: treeDoc.meta })
|
||||
b.resources.value = markRaw(res)
|
||||
b.loading.value = false
|
||||
if (open) b.openSlug.value = open.slug
|
||||
if (open) { b.openSlug.value = open.slug; b.bus.setSelection([open.slug]) }
|
||||
// narrow to the largest family through the tree, as a click would
|
||||
const fam = b.tree.value.nodes.filter((n) => !n.p).sort((x, y) => b.tree.value.isos.get(y.id).size - b.tree.value.isos.get(x.id).size)[0]
|
||||
b.setTreeNode(fam.id)
|
||||
|
|
@ -86,13 +86,28 @@ try {
|
|||
const en = await render('en')
|
||||
ok(en.errors.length === 0, `en: every panel rendered with no error (${en.errors.length}${en.errors[0] ? ': ' + en.errors[0].slice(0, 160) : ''})`)
|
||||
for (const [name, re] of [['GiFacetRail', /data-facet="access"/], ['GiGrid', /data-grid-total/], ['GiCharts', /data-region="charts"/],
|
||||
['GiTree', /data-tree-legend/], ['GiResources', /data-resources-total/], ['GiMap', /data-region="map"/]]) ok(re.test(en.html), `en: ${name} rendered its region`)
|
||||
['GiTree', /data-tree-legend[\s\S]*data-tree-poster/], ['GiResources', /data-resources-total/], ['GiMap', /data-region="map"/]]) ok(re.test(en.html), `en: ${name} rendered its region`)
|
||||
ok(!!open && en.html.includes(open.people_group), `en: the drawer shows the opened group (${open ? open.people_group : 'no row'})`)
|
||||
ok(/data-notice="approximate-pin"/.test(en.html), 'en: the drawer carries the approximate-pin notice')
|
||||
const fam = treeDoc.nodes.filter((n) => !n.p).sort((a, b) => b.n.length - a.n.length)
|
||||
// interop out: the selection opens in Gospel Resources by its ?groups= contract, carrying the slug
|
||||
const gl = (en.html.match(/href="([^"]*)"[^>]*data-open-gospel/) || [])[1] || ''
|
||||
ok(!!open && gl.includes('gospel-resources/resources-map/index.html') && decodeURIComponent(gl).includes('groups=' + open.slug) && !POISON,
|
||||
`en: the selection links out to Gospel Resources with ?groups= (${gl.split('?')[1] || 'no link'})`)
|
||||
ok(/data-tree-card/.test(en.html), 'en: the tree card shows the node the board is narrowed to')
|
||||
ok(/href="https?:\/\/[^"]+"/.test(en.html.slice(en.html.indexOf('data-region="resources"'))), 'en: the resources tab lists real links')
|
||||
ok(/\b\d[\d,]* of [\d,]+ people groups\b/.test(en.html), 'en: the grid prints its scope count')
|
||||
// the selection strip: the kit's chips name the TREE NODE (not 120 language codes), and a combination
|
||||
// carries its live count (nothing in own language x 60%+ online = 220 on the whole roster)
|
||||
const fam = treeDoc.nodes.filter((n) => !n.p).sort((x, y) => 0)[0]
|
||||
const famNode = (() => { const ix = M.indexTree(treeDoc); return ix.nodes.filter((n) => !n.p).sort((x, y) => ix.isos.get(y.id).size - ix.isos.get(x.id).size)[0] })()
|
||||
ok(/data-chip="language"/.test(en.html) && en.html.includes(famNode.n), `en: the kit chip names the tree node (${famNode.n})`)
|
||||
// the charts answer their question in words (the harness narrows to a family first, so assert the
|
||||
// takeaways' FORM and that the gap charts name real groups/languages, not the roster's totals)
|
||||
const plain = en.html.replace(/<!--[\s\S]*?-->/g, '')
|
||||
ok(/\d[\d,]* of the \d[\d,]* with nothing live in countries/.test(plain) && !POISON, 'en: chart 3 states its takeaway')
|
||||
ok(/data-chart="gaps"[\s\S]*?These \d+ alone are [\d,]+ people/.test(plain), 'en: the gap chart names its groups and totals them')
|
||||
ok(/data-chart="ntNeed"[\s\S]*?languages here have no New Testament found/.test(plain), 'en: the New Testament chart states its takeaway')
|
||||
ok(new RegExp('data-combo="digital-now"[^>]*>\\s*<span>[^<]+</span><span class="gic2-n">' + (POISON ? 'x' : '220')).test(en.html), 'en: a combination shows its live count (digital-now = 220)')
|
||||
void fam
|
||||
const ar = await render('ar')
|
||||
ok(ar.errors.length === 0, `ar: every panel rendered with no error (${ar.errors.length})`)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue