Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/ax/ui/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ _Avoid_: equipment list, generated menu.
The small mutable object (`state.py`) holding UI-process state — observing list, recent objects, target, message/hint timeouts, FPS flag. Installed on `shared_state` via `set_ui_state`; every module reads `shared_state.ui_state()`.
_Avoid_: ui config, session state.

**Target** (`ui_state.target()`):
The most-recently **selected object**, mirrored into `UIState` by `UIObjectDetails` (`update_object_info`) so cross-screen consumers can mark it — the **chart** draws it as a full-brightness cross (+ off-screen pointer, and its designator label when on-screen), and telemetry records it. Distinct from the Catalog *selected object* (the live `UIObjectDetails` cursor, see [Catalog](../catalog/CONTEXT.md)): the target is the **persisted last selection**, surviving after you leave details. Not a push-to concept — it follows selection automatically.
_Avoid_: push-to target, goto target.

**Published UI state** (`serialize_current_ui_state`):
The dict `MenuManager` writes to `shared_state.set_current_ui_state(...)` each redraw — `ui_type`, `title`, marking-menu options, and the active module's own `serialize_ui_state()`. This is what `/api/current-selection` reflects.
_Avoid_: api state, ui snapshot.
Expand Down
13 changes: 13 additions & 0 deletions docs/source/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,19 @@ where your telescope is pointing, with constellation lines and markers for nearb
It redraws as you move the scope, so it's a quick way to see what's around you and confirm
your aim. Zoom in and out with the **+/-** keys.

Deep-sky objects show as small symbols, one shape per object type. Near where you're
pointing the chart marks the objects your :ref:`filters <user_guide:filters>` allow,
revealing fainter ones as you zoom in and keeping only the brightest as you zoom out so the
field never crowds. Objects on a loaded observing list are marked too. Dim these markers,
or switch them off, with the DSO Display setting under Chart... in the
:ref:`user_guide:settings menu`.

The object you last opened in :ref:`user_guide:object details` is marked with a brighter
cross wherever you steer, a quick way to see where it sits relative to your aim. On the
chart the cross is labelled with the object's designation — "M 57", say; once it drifts off
the edge an arrow at the rim points the way instead. The cross stays bright even with DSO
Display turned off.

How the chart is turned is up to you. Choose Coordinate Sys. under Chart... in the
:ref:`user_guide:settings menu`:

Expand Down
5 changes: 5 additions & 0 deletions python/PiFinder/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,11 @@ def main(
logger.info(
"All catalogs loaded - WDS and extended catalogs available"
)
# Mark the filter dirty so downstream consumers that cache
# off dirty_time (e.g. the chart's nearby-DSO spatial index)
# rebuild to include the newly available objects.
if catalogs.catalog_filter is not None:
catalogs.catalog_filter.mark_dirty()
menu_manager.message(_("Catalogs\nFully Loaded"), 2)
elif ui_command == "test_mode":
dt = timez.utc(2025, 6, 28, 11, 0, 0)
Expand Down
20 changes: 20 additions & 0 deletions python/PiFinder/nearby.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,26 @@ def get_closest_objects(self, ra, dec, n: int = 0) -> List[CompositeObject]:
# logger.debug("Found %i objects, from %i objects, n=%i", len(results), nr_objects, n)
return results

def get_objects_within_radius(
self, ra, dec, radius_deg: float
) -> List[CompositeObject]:
"""
Returns every object within ``radius_deg`` great-circle degrees of
ra/dec (unordered). Uses the haversine BallTree's ``query_radius``,
so the radius is converted to radians. Returns ``[]`` when the tree
is empty. Unlike ``get_closest_objects`` (k-NN), this bounds the
result by angular distance rather than count -- what the chart needs
to plot the objects that actually fall inside the current field.
"""
if self._objects_balltree is None or self._objects is None:
return []
if len(self._objects) == 0:
return []

query = [[np.deg2rad(ra), np.deg2rad(dec)]]
obj_ind = self._objects_balltree.query_radius(query, r=np.deg2rad(radius_deg))
return list(self._objects[obj_ind[0]])


def deduplicate_objects(
unfiltered_objects: list[CompositeObject],
Expand Down
196 changes: 172 additions & 24 deletions python/PiFinder/ui/chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,44 @@
from PiFinder import plot
from PiFinder.ui.base import UIModule
from PiFinder import calc_utils
from PiFinder.composite_object import MagnitudeObject
from PiFinder.nearby import ClosestObjectsFinder


logger = logging.getLogger("Chart")

# --- Nearby-DSO marker tuning ------------------------------------------------
# Starting values; tune on-device (see the chart-markers handoff). The radius
# query fetches catalog objects within ``fov * NEARBY_RADIUS_FACTOR`` degrees of
# the pointing, then the mag/cap filters below decide which get drawn.
NEARBY_RADIUS_FACTOR = 0.75
# When more than this many objects survive the mag filter, keep the brightest.
NEARBY_MARKER_CAP = 20
# Linear magnitude-limit curve over the chart's full zoom range: more zoomed in
# (small FOV) -> show dimmer objects. Endpoints are (fov_deg, mag_limit) pairs.
_MAG_LIMIT_LO = (5.0, 11.0)
_MAG_LIMIT_HI = (60.0, 7.0)


def dso_mag_limit(fov: float) -> float:
"""
Magnitude limit for nearby DSO markers as a function of chart FOV.

Linear between the two hard-coded endpoints and clamped outside the
chart's zoom range: 5deg -> mag 11 (zoomed in, show dimmer objects),
60deg -> mag 7 (zoomed out, only the brightest). Kept deliberately
separate from ``plot.Starfield.set_fov``'s *star* mag limit -- different
curve, different purpose (DSO markers vs Hipparcos stars).
"""
fov_lo, mag_lo = _MAG_LIMIT_LO
fov_hi, mag_hi = _MAG_LIMIT_HI
if fov <= fov_lo:
return mag_lo
if fov >= fov_hi:
return mag_hi
perc = (fov - fov_lo) / (fov_hi - fov_lo)
return mag_lo + (mag_hi - mag_lo) * perc


class UIChart(UIModule):
__title__ = "CHART"
Expand All @@ -43,6 +77,14 @@ def __init__(self, *args, **kwargs):
self.fov = self.desired_fov
self.set_fov(self.desired_fov)

# Spatial index for the "nearby catalog DSOs" marker layer. Rebuilt
# from the active "All Filtered" set only when the catalog filter
# changes (tracked via dirty_time) or deferred catalogs finish loading
# -- never per frame. The radius query itself runs on the new-solve
# path in plot_markers().
self._nearby_finder = ClosestObjectsFinder()
self._nearby_filter_dirty_time = None

# Marking menu definition
self.marking_menu = MarkingMenu(
left=MarkingMenuOption(),
Expand All @@ -55,42 +97,40 @@ def __init__(self, *args, **kwargs):

def plot_markers(self):
"""
Plot the contents of the observing list
and target if there is one
Plot the chart's DSO markers, in three deduped layers:

* The **target** cross -- the last-viewed object (``ui_state.target()``)
-- always drawn at full brightness and independent of ``chart_dso``,
with its designator label when it's on-screen.
* The **observing list** (loaded from a saved list) -- always on, no
mag limit, uncapped.
* **Nearby catalog DSOs** -- objects from the active "All Filtered" set
that fall inside the field, magnitude-filtered by zoom and capped.

Only called on the new-solve path, so the radius query runs at most
once per solve (~1-2 Hz). ``chart_dso`` scales the two DSO layers but
never the target cross.
"""
if not self.solution:
return

marker_list = []
vertex_objects = []
W, H = self.display_class.resolution

# is there a target?
# --- Target cross: always drawn, full brightness, chart_dso-independent
target = self.ui_state.target()
if target:
marker_list.append(
(plot.Angle(degrees=target.ra)._hours, target.dec, "target")
)
if target.size.is_vertices:
vertex_objects.append(target)
exclude_ids = set()
if target is not None and target.ra is not None and target.dec is not None:
exclude_ids.add(target.object_id)
self._draw_target(target, W, H)

marker_brightness = self.config_object.get_option("chart_dso", 128)
if marker_brightness == 0:
return

for obs_target in self.ui_state.observing_list():
if obs_target.size.is_vertices:
vertex_objects.append(obs_target)
marker = OBJ_TYPE_MARKERS.get(obs_target.obj_type)
if marker:
marker_list.append(
(
plot.Angle(degrees=obs_target.ra)._hours,
obs_target.dec,
marker,
)
)
# --- DSO layers (observing list + nearby), deduped against the target
marker_list, vertex_objects = self._collect_dso_markers(exclude_ids)

if marker_list != []:
if marker_list:
marker_image = self.starfield.plot_markers(
marker_list,
)
Expand All @@ -112,6 +152,114 @@ def plot_markers(self):
if len(screen_pts) >= 2:
self.draw.line(screen_pts, fill=line_color, width=1)

def _draw_target(self, target, W, H):
"""
Draw the target cross (+ off-screen pointer) at full brightness, and
its designator label when the target is on-screen. Rendered separately
from the ``chart_dso``-scaled DSO layers so it stays fully bright and
visible even when ``chart_dso`` is 0.
"""
target_image = self.starfield.plot_markers(
[(plot.Angle(degrees=target.ra)._hours, target.dec, "target")]
)
target_image = ImageChops.multiply(
target_image,
Image.new("RGB", self.display_class.resolution, self.colors.get(255)),
)
self.screen.paste(ImageChops.add(self.screen, target_image))

# Designator label only when the cross itself is on-screen; off-screen
# the pointer arrow (drawn above) already communicates direction.
tx, ty = self.starfield.radec_to_xy(target.ra, target.dec)
if 0 <= tx < W and 0 <= ty < H:
self.draw.text(
(int(tx) + 6, int(ty) - 4),
target.display_name,
font=self.fonts.base.font,
fill=self.colors.get(255),
)

def _collect_dso_markers(self, exclude_ids):
"""
Build the marker list for the observing-list and nearby-catalog layers,
deduped by ``object_id`` with precedence target -> observing-list ->
nearby (``exclude_ids`` seeds the target). Returns
``(marker_list, vertex_objects)`` where marker_list holds
``(ra_hours, dec_deg, symbol)`` tuples for ``Starfield.plot_markers``
and vertex_objects holds asterism-polyline objects (observing list
only; nearby markers are symbols only).
"""
marker_list = []
vertex_objects = []
seen = set(exclude_ids)

# Observing list: always on, uncapped, no mag limit.
for obj in self.ui_state.observing_list():
if obj.object_id in seen:
continue
seen.add(obj.object_id)
if obj.size.is_vertices:
vertex_objects.append(obj)
symbol = OBJ_TYPE_MARKERS.get(obj.obj_type)
if symbol:
marker_list.append((plot.Angle(degrees=obj.ra)._hours, obj.dec, symbol))

# Nearby catalog DSOs: symbols only.
for obj in self._get_nearby_markers():
if obj.object_id in seen:
continue
seen.add(obj.object_id)
symbol = OBJ_TYPE_MARKERS.get(obj.obj_type)
if symbol:
marker_list.append((plot.Angle(degrees=obj.ra)._hours, obj.dec, symbol))

return marker_list, vertex_objects

def _get_nearby_markers(self):
"""
Catalog objects near the current pointing to draw as nearby markers:
drawn from the active "All Filtered" set, restricted to drawable object
types, magnitude-filtered for the current FOV (unknown mags hidden),
and capped at ``NEARBY_MARKER_CAP`` keeping the brightest.

The BallTree is (re)built only when the catalog filter's ``dirty_time``
changes (filter edits) or deferred catalogs finish loading (which marks
the filter dirty) -- otherwise the cached tree is reused. The radius
query runs each call, but plot_markers only calls this on a new solve.
"""
if self.catalogs is None:
return []

catalog_filter = getattr(self.catalogs, "catalog_filter", None)
dirty_time = getattr(catalog_filter, "dirty_time", None)
if dirty_time != self._nearby_filter_dirty_time:
objects = self.catalogs.get_objects(only_selected=True, filtered=True)
self._nearby_finder.calculate_objects_balltree(objects)
self._nearby_filter_dirty_time = dirty_time

aligned = self.solution.pointing.aligned.estimate
radius = self.fov * NEARBY_RADIUS_FACTOR
candidates = self._nearby_finder.get_objects_within_radius(
aligned.RA, aligned.Dec, radius
)

mag_limit = dso_mag_limit(self.fov)
eligible = []
for obj in candidates:
if OBJ_TYPE_MARKERS.get(obj.obj_type) is None:
continue
mag = obj.mag
if mag is None:
continue
filter_mag = mag.filter_mag
if filter_mag == MagnitudeObject.UNKNOWN_MAG or filter_mag > mag_limit:
continue
eligible.append((filter_mag, obj))

# Brightest first, then keep at most NEARBY_MARKER_CAP.
eligible.sort(key=lambda pair: pair[0])
return [obj for _, obj in eligible[:NEARBY_MARKER_CAP]]

def _draw_orientation_indicator(self, orientation: "ChartOrientation"):
"""
Draw a small "up" indicator at the top-left of the chart.
Expand Down
7 changes: 7 additions & 0 deletions python/PiFinder/ui/object_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,13 @@ def update_object_info(self):
"""
Generates object text and loads object images
"""
# Mirror the just-selected object into UIState as the chart/telemetry
# "target" (see docs/ax/ui/CONTEXT.md). This is the single chokepoint
# where the displayed object is (re)set -- open, scroll, eyepiece cycle
# and display-mode cycle all route through here -- so the chart's target
# cross and the telemetry poller track the last-viewed object.
self.ui_state.set_target(self.object)

# Title...
self.title = self.object.display_name

Expand Down
Loading
Loading