This commit is contained in:
@@ -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
@@ -4,7 +4,11 @@ import "math"
|
||||
|
||||
// Camera controls pan/zoom over an equirectangular world map.
|
||||
// Matches web viz defaults: zoom 0.45–3.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
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user