Raster
ci / test (push) Successful in 57s
ci / release (push) Successful in 1m32s

This commit is contained in:
2026-07-10 15:41:54 -04:00
parent b3bc9cd252
commit f8a31be3a0
5 changed files with 107 additions and 83 deletions
BIN
View File
Binary file not shown.
+23
View File
@@ -36,6 +36,29 @@ func TestProjectInBounds(t *testing.T) {
_ = okN
}
func TestProjectFillsHorizontalSpace(t *testing.T) {
// Wide short viewport — old square/height-limited fit left huge side margins.
cam := DefaultCamera()
w, h := 160, 20
// Western and eastern extremes of the globe must land near the panel edges.
xW, _, okW := Project(0, -170, cam, w, h)
xE, _, okE := Project(0, 170, cam, w, h)
if !okW || !okE {
t.Fatalf("expected ±170° on map, west=%v east=%v", okW, okE)
}
if xW > w/8 {
t.Fatalf("west edge too far inland: x=%d (want near 0 in width %d)", xW, w)
}
if xE < w*7/8 {
t.Fatalf("east edge too far inland: x=%d (want near %d)", xE, w)
}
// Full span should use most of the width.
span := xE - xW
if span < w*3/4 {
t.Fatalf("map span %d too narrow for width %d", span, w)
}
}
func TestZoomClamp(t *testing.T) {
c := Camera{Zoom: 0.01}
c.ClampZoom()
+68 -65
View File
@@ -4,7 +4,11 @@ import "math"
// Camera controls pan/zoom over an equirectangular world map.
// Matches web viz defaults: zoom 0.453.5.
//
// Projection fills the entire viewport (independent X/Y scales), like the web
// canvas setTransform(w/2000, 0, 0, h/857) — so wide terminals use full width.
type Camera struct {
// PanX/PanY are offsets in normalized world units ([-0.5,0.5] spans the globe).
PanX, PanY float64
Zoom float64
}
@@ -12,9 +16,11 @@ type Camera struct {
const (
MinZoom = 0.45
MaxZoom = 3.5
// fillPad leaves a tiny margin so coasts aren't clipped by the border.
fillPad = 0.99
)
// DefaultCamera returns a fitted world view.
// DefaultCamera returns a fitted world view that fills the panel.
func DefaultCamera() Camera {
return Camera{Zoom: 1}
}
@@ -34,55 +40,69 @@ func (c *Camera) ClampZoom() {
}
}
// ZoomAt multiplies zoom, keeping the given cell roughly under the cursor.
func (c *Camera) ZoomAt(factor float64, cellX, cellY, charsW, charsH int) {
if factor <= 0 || charsW < 1 || charsH < 1 {
return
}
old := c.Zoom
if old < MinZoom {
old = MinZoom
}
// World coords under cursor before zoom.
wx := (float64(cellX)-float64(charsW)/2)/old - c.PanX
wy := (float64(cellY)-float64(charsH)/2)/old - c.PanY
c.Zoom = old * factor
c.ClampZoom()
// Adjust pan so the same world point stays under cursor.
c.PanX = (float64(cellX)-float64(charsW)/2)/c.Zoom - wx
c.PanY = (float64(cellY)-float64(charsH)/2)/c.Zoom - wy
}
// PanBy shifts the camera in cell units (screen space).
func (c *Camera) PanBy(dx, dy float64) {
if c.Zoom < MinZoom {
c.Zoom = MinZoom
}
c.PanX += dx / c.Zoom
c.PanY += dy / c.Zoom
}
// Project maps lat/lng to character-cell coordinates.
// Equirectangular: x ∝ lng, y ∝ -lat, centered, scaled by zoom + pan.
func Project(lat, lng float64, cam Camera, charsW, charsH int) (cellX, cellY int, ok bool) {
if charsW < 1 || charsH < 1 {
return 0, 0, false
}
// scales returns cell-space scale factors for X and Y at the given viewport.
// At zoom=1, the full globe spans nearly the entire charsW × charsH rectangle.
func scales(cam Camera, charsW, charsH int) (scaleX, scaleY float64) {
zoom := cam.Zoom
if zoom < MinZoom {
zoom = MinZoom
}
scaleX = float64(charsW) * fillPad * zoom
scaleY = float64(charsH) * fillPad * zoom
return scaleX, scaleY
}
// ZoomAt multiplies zoom, keeping the given cell under the cursor.
func (c *Camera) ZoomAt(factor float64, cellX, cellY, charsW, charsH int) {
if factor <= 0 || charsW < 1 || charsH < 1 {
return
}
oldX, oldY := scales(*c, charsW, charsH)
if oldX <= 0 || oldY <= 0 {
return
}
// Normalized world under cursor before zoom.
nx := (float64(cellX)-float64(charsW)/2)/oldX - c.PanX
ny := (float64(cellY)-float64(charsH)/2)/oldY - c.PanY
c.Zoom *= factor
c.ClampZoom()
newX, newY := scales(*c, charsW, charsH)
// Adjust pan so the same world point stays under cursor.
c.PanX = (float64(cellX)-float64(charsW)/2)/newX - nx
c.PanY = (float64(cellY)-float64(charsH)/2)/newY - ny
}
// PanBy shifts the camera in cell units (screen space).
func (c *Camera) PanBy(dx, dy float64) {
// Approximate using unit viewport; actual pan is refined on next Project.
// Use a stable reference size so pan speed feels consistent.
c.PanX += dx / 80
c.PanY += dy / 40
}
// PanByViewport pans by cell deltas for the current map size.
func (c *Camera) PanByViewport(dx, dy float64, charsW, charsH int) {
sx, sy := scales(*c, charsW, charsH)
if sx > 0 {
c.PanX += dx / sx
}
if sy > 0 {
c.PanY += dy / sy
}
}
// Project maps lat/lng to character-cell coordinates, filling the viewport.
// Equirectangular: x ∝ lng, y ∝ -lat (same as web equirectangularProjection).
func Project(lat, lng float64, cam Camera, charsW, charsH int) (cellX, cellY int, ok bool) {
if charsW < 1 || charsH < 1 {
return 0, 0, false
}
scaleX, scaleY := scales(cam, charsW, charsH)
// Normalized world coords in [-0.5, 0.5] for full globe.
nx := lng / 360.0
ny := -lat / 180.0
// Fit globe to ~90% of the smaller dimension so poles have margin.
base := float64(charsW)
if float64(charsH)*2 < base {
base = float64(charsH) * 2
}
scale := base * 0.92 * zoom
cx := float64(charsW)/2 + (nx+cam.PanX/float64(charsW)*scale)*scale
cy := float64(charsH)/2 + (ny+cam.PanY/float64(charsH)*scale)*scale
cx := float64(charsW)/2 + (nx+cam.PanX)*scaleX
cy := float64(charsH)/2 + (ny+cam.PanY)*scaleY
x := int(math.Round(cx))
y := int(math.Round(cy))
if x < 0 || y < 0 || x >= charsW || y >= charsH {
@@ -91,34 +111,17 @@ func Project(lat, lng float64, cam Camera, charsW, charsH int) (cellX, cellY int
return x, y, true
}
// Unproject maps a cell back to approximate lat/lng (for hit-testing helpers).
// Unproject maps a cell back to approximate lat/lng.
func Unproject(cellX, cellY int, cam Camera, charsW, charsH int) (lat, lng float64) {
if charsW < 1 || charsH < 1 {
return 0, 0
}
zoom := cam.Zoom
if zoom < MinZoom {
zoom = MinZoom
}
base := float64(charsW)
if float64(charsH)*2 < base {
base = float64(charsH) * 2
}
scale := base * 0.92 * zoom
if scale == 0 {
scaleX, scaleY := scales(cam, charsW, charsH)
if scaleX == 0 || scaleY == 0 {
return 0, 0
}
nx := (float64(cellX)-float64(charsW)/2)/scale - cam.PanX/float64(charsW)*scale/scale
ny := (float64(cellY)-float64(charsH)/2)/scale - cam.PanY/float64(charsH)*scale/scale
// cam.Pan is in cell units divided by zoom in PanBy — keep consistent with Project:
nx = (float64(cellX) - float64(charsW)/2) / scale
ny = (float64(cellY) - float64(charsH)/2) / scale
// Subtract pan contribution as used in Project:
// cx = W/2 + (nx + panX/W*scale) * scale
// => (cx - W/2)/scale = nx + panX/W*scale
// => nx = (cx - W/2)/scale - panX/W*scale
nx = (float64(cellX)-float64(charsW)/2)/scale - cam.PanX/float64(charsW)*scale
ny = (float64(cellY)-float64(charsH)/2)/scale - cam.PanY/float64(charsH)*scale
nx := (float64(cellX)-float64(charsW)/2)/scaleX - cam.PanX
ny := (float64(cellY)-float64(charsH)/2)/scaleY - cam.PanY
lng = nx * 360.0
lat = -ny * 180.0
return lat, lng
+10 -16
View File
@@ -225,31 +225,25 @@ func (r *raster) cellFillCount(cx, cy int) int {
}
// projectPixel maps lat/lng to raster pixel coords using the same camera model
// as Project, but at braille subpixel resolution.
// as Project, but at braille subpixel resolution (2×4 dots per cell).
func projectPixel(lat, lng float64, cam Camera, charsW, charsH int) (px, py int, ok bool) {
if charsW < 1 || charsH < 1 {
return 0, 0, false
}
zoom := cam.Zoom
if zoom < MinZoom {
zoom = MinZoom
}
// Project in cell space, then scale to pixels — keeps fill aligned with Project().
cx, cy, _ := Project(lat, lng, cam, charsW, charsH)
// Recompute without viewport clipping so edges still rasterize.
scaleX, scaleY := scales(cam, charsW, charsH)
nx := lng / 360.0
ny := -lat / 180.0
base := float64(charsW)
if float64(charsH)*2 < base {
base = float64(charsH) * 2
}
scale := base * 0.92 * zoom
cx := float64(charsW)/2 + (nx+cam.PanX/float64(charsW)*scale)*scale
cy := float64(charsH)/2 + (ny+cam.PanY/float64(charsH)*scale)*scale
// Convert cell coords to pixel coords (2×4).
fx := cx * 2
fy := cy * 4
fx := (float64(charsW)/2 + (nx+cam.PanX)*scaleX) * 2
fy := (float64(charsH)/2 + (ny+cam.PanY)*scaleY) * 4
px = int(math.Round(fx))
py = int(math.Round(fy))
_ = cx
_ = cy
pw, ph := charsW*2, charsH*4
if px < -4 || py < -4 || px >= pw+4 || py >= ph+4 {
if px < -8 || py < -8 || px >= pw+8 || py >= ph+8 {
return px, py, false
}
return px, py, true
+6 -2
View File
@@ -214,7 +214,9 @@ func (m *Model) handleMapExtraKeys(msg tea.KeyMsg) (bool, tea.Cmd) {
switch msg.String() {
case "left", "h":
cam := m.geoMap.Camera()
cam.PanBy(-3, 0)
mainH, contentW := m.layoutMetrics()
mapH, _ := mapPanelHeights(mainH)
cam.PanByViewport(-4, 0, maxInt(8, contentW), maxInt(4, mapH-1))
m.geoMap.SetCamera(cam)
return true, nil
case "right", "l":
@@ -223,7 +225,9 @@ func (m *Model) handleMapExtraKeys(msg tea.KeyMsg) (bool, tea.Cmd) {
return false, nil
}
cam := m.geoMap.Camera()
cam.PanBy(3, 0)
mainH, contentW := m.layoutMetrics()
mapH, _ := mapPanelHeights(mainH)
cam.PanByViewport(4, 0, maxInt(8, contentW), maxInt(4, mapH-1))
m.geoMap.SetCamera(cam)
return true, nil
case "0":