Compare commits

...

16 Commits

14 changed files with 569 additions and 0 deletions

8
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

7
.idea/discord.xml Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.10" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/train-journey-visuals.iml" filepath="$PROJECT_DIR$/.idea/train-journey-visuals.iml" />
</modules>
</component>
</project>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

13
.idea/vcs.xml Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CommitMessageInspectionProfile">
<profile version="1.0">
<inspection_tool class="SubjectLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="RIGHT_MARGIN" value="50" />
</inspection_tool>
</profile>
</component>
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

43
display.py Normal file
View File

@ -0,0 +1,43 @@
from typing import Optional
import pygame
class Display:
APP_NAME = "Train Journey"
def __init__(self, surf: pygame.Surface):
self.surf: pygame.Surface = surf
self.font: pygame.font.Font = pygame.font.SysFont("ubuntu", 20)
self._tooltip_surf: Optional[pygame.Surface] = None
def mainloop(self) -> None:
running = True
self.init_interactive()
clock = pygame.time.Clock()
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
elif event.key == pygame.K_s and event.mod & pygame.KMOD_CTRL:
path = "/tmp/image.jpg"
pygame.image.save(self.surf, path)
print(f"Saved as {path}")
pygame.display.set_caption(f"{self.APP_NAME} - {clock.get_fps():.2f}fps")
self.render()
pygame.display.flip()
clock.tick(30)
def init_interactive(self) -> None:
self._tooltip_surf = pygame.Surface(self.surf.get_size(), pygame.SRCALPHA)
def render(self) -> None:
pass

56
gps_loader.py Normal file
View File

@ -0,0 +1,56 @@
from units import Value, Unit
from vec import Vec2
class GPSLoader:
@staticmethod
def load_data(filename: str) -> dict:
data = {
"times": [],
"points": [],
"altitudes": [],
"altitudes_wgs84": [],
"speeds": [],
"directions": [],
"distances": [],
"accuracies": [],
"satellites": []
}
# time [s], lat, lon, alt [m], alt_wgs84 [m], speed [m/s], dir [°], dist [km], x_acc [m], y_acc [m], sats
with open(filename, "r") as f:
content = f.read()
lines = content.splitlines()
headers, lines = lines[0], lines[1:]
for line in lines:
values = line.split(",")
values = list(map(lambda v: None if v == "NaN" else float(v), values))
if None in values:
continue
time = Value(values[0], Unit.SEC)
lat = Value(values[1], Unit.DEG)
lon = Value(values[2], Unit.DEG)
alt = Value(values[3], Unit.M)
alt_wgs84 = Value(values[4], Unit.M)
speed = Value(values[5], Unit.M_S)
dir_ = Value(values[6], Unit.DEG)
dist = Value(values[7], Unit.KM)
x_acc = Value(values[8], Unit.M)
y_acc = Value(values[9], Unit.M)
sats = Value(values[10], Unit.NONE)
data["times"].append(time)
data["points"].append(Vec2(lon.value, lat.value))
data["altitudes"].append(alt)
data["altitudes_wgs84"].append(alt_wgs84)
data["speeds"].append(speed)
data["directions"].append(dir_)
data["distances"].append(dist)
data["accuracies"].append(Vec2(x_acc.value, y_acc.value))
data["satellites"].append(sats.value)
return data

110
map_display.py Normal file
View File

@ -0,0 +1,110 @@
from typing import Optional
import pygame
from display import Display
from path import Path
from vec import Vec2
class MapDisplay(Display):
PATH_WIDTH = 5
SEGMENT_SIZE = 20
MIN_SEGMENT_LENGTH = 5
APP_NAME = "Train Journey - Map Display"
def __init__(self,
surf: pygame.Surface,
min_lon: float,
max_lon: float,
min_lat: float,
max_lat: float,
cities: list[tuple[Vec2, str, str]]):
super().__init__(surf)
self.min_lon: float = min_lon
self.max_lon: float = max_lon
self.min_lat: float = min_lat
self.max_lat: float = max_lat
self._precomputeDisplayValues()
self.cities: list[tuple[Vec2, str, str]] = cities
def _precomputeDisplayValues(self) -> None:
width, height = self.surf.get_size()
self._lon_span = self.max_lon - self.min_lon
self._lat_span = self.max_lat - self.min_lat
r1 = width / self._lon_span
r2 = height / self._lat_span
self._r = min(r1, r2)
self._ox = (width - self._lon_span * self._r) / 2
self._oy = (height - self._lat_span * self._r) / 2
def real_to_screen(self, lon: float, lat: float) -> tuple[float, float]:
x = (lon - self.min_lon) * self._r + self._ox
y = (lat - self.min_lat) * self._r + self._oy
return x, self.surf.get_height() - y
def draw_path(self, path: Path) -> None:
self.draw_colored_path(path, None)
def draw_colored_path(self, path: Path, colors: Optional[list[tuple[int, int, int]]] = None) -> None:
for i, pt in enumerate(path.points):
lon = pt.x
lat = pt.y
x, y = self.real_to_screen(lon, lat)
col = (255, 255, 255) if colors is None else colors[i]
pygame.draw.circle(self.surf, col, (x, y), self.PATH_WIDTH)
def draw_segment(self, path: Path, start_i: int, end_i: int) -> None:
if end_i - start_i < self.MIN_SEGMENT_LENGTH:
return
points = []
for i in range(start_i, end_i):
pt = path.points[i]
pt = Vec2(*self.real_to_screen(pt.x, pt.y))
n = path.normals[i]
n = Vec2(n.x, -n.y)
pt1 = pt + n * self.SEGMENT_SIZE
pt2 = pt - n * self.SEGMENT_SIZE
points.insert(0, (pt1.x, pt1.y))
points.append((pt2.x, pt2.y))
pygame.draw.lines(self.surf, (255, 255, 255), True, points)
def draw_cities(self) -> None:
for city in self.cities:
self.draw_city(*city)
def draw_city(self, pos: Vec2, name: str, label_side: str) -> None:
pos2 = Vec2(*self.real_to_screen(pos.x, pos.y))
pygame.draw.circle(self.surf, (180, 180, 180), (pos2.x, pos2.y), 10)
label = self.font.render(name, True, (255, 255, 255))
label_size = Vec2(*label.get_size())
line_end = pos2
label_pos = line_end - label_size / 2
if label_side == "above":
line_end -= Vec2(0, 20)
label_pos = line_end - label_size.scale(Vec2(0.5, 1))
elif label_side == "below":
line_end += Vec2(0, 20)
label_pos = line_end - label_size.scale(Vec2(0.5, 0))
elif label_side == "left":
line_end -= Vec2(20, 0)
label_pos = line_end - label_size.scale(Vec2(1, 0.5))
elif label_side == "right":
line_end += Vec2(20, 0)
label_pos = line_end - label_size.scale(Vec2(0, 0.5))
pygame.draw.line(self.surf, (255, 255, 255), (pos2.x, pos2.y), (line_end.x, line_end.y))
self.surf.blit(label, (label_pos.x, label_pos.y))

29
path.py Normal file
View File

@ -0,0 +1,29 @@
from vec import Vec2
class Path:
def __init__(self, points: list[Vec2], extra_data: list):
self.points: list[Vec2] = points
self.extra_data = extra_data
self.normals: list[Vec2] = []
self._init_normals()
def _init_normals(self) -> None:
pt0 = self.points[0]
self.normals.append(Vec2(pt0.y, -pt0.x))
for i in range(1, len(self.points) - 1):
pt1 = self.points[i-1]
pt2 = self.points[i]
pt3 = self.points[i+1]
d1 = (pt1 - pt2).normalized()
d2 = (pt3 - pt2).normalized()
d = (d1 + d2).normalized()
if d2.cross(d) < 0:
d = -d
self.normals.append(d)
ptl = self.points[-1]
self.normals.append(Vec2(ptl.y, -ptl.x))

127
speed_map_display.py Normal file
View File

@ -0,0 +1,127 @@
from typing import Optional
import pygame
from gps_loader import GPSLoader
from map_display import MapDisplay
from path import Path
from units import Unit
from vec import Vec2
class SpeedMapDisplay(MapDisplay):
APP_NAME = "Train Journey - Speed Map Display"
def __init__(self,
surf: pygame.Surface,
min_lon: float,
max_lon: float,
min_lat: float,
max_lat: float,
cities: list[tuple[Vec2, str, str]],
min_speed_col: tuple[int, int, int],
max_speed_col: tuple[int, int, int],
segment_threshold: float):
super().__init__(surf, min_lon, max_lon, min_lat, max_lat, cities)
self.min_speed_col: tuple[int, int, int] = min_speed_col
self.max_speed_col: tuple[int, int, int] = max_speed_col
self.segment_threshold: float = segment_threshold
self._path: Optional[Path] = None
def draw_path(self, path: Path) -> None:
min_speed = min(path.extra_data)
max_speed = max(path.extra_data)
colors = list(map(lambda s: self.interpolate_color(s, min_speed, max_speed), path.extra_data))
self.draw_colored_path(path, colors)
in_segment = False
start_i = 0
for i, speed in enumerate(path.extra_data):
if speed >= self.segment_threshold:
if not in_segment:
in_segment = True
start_i = i
elif in_segment:
in_segment = False
self.draw_segment(path, start_i, i)
def interpolate_color(self, speed: float, min_speed: float, max_speed: float) -> tuple[int, int, int]:
r_span = self.max_speed_col[0] - self.min_speed_col[0]
g_span = self.max_speed_col[1] - self.min_speed_col[1]
b_span = self.max_speed_col[2] - self.min_speed_col[2]
f = (speed - min_speed) / (max_speed - min_speed)
r = int(r_span * f + self.min_speed_col[0])
g = int(g_span * f + self.min_speed_col[1])
b = int(b_span * f + self.min_speed_col[2])
return r, g, b
def render(self) -> None:
self.surf.fill((0, 0, 0))
self._tooltip_surf.fill((0, 0, 0, 0))
self.draw_cities()
if self._path is not None:
self.draw_path(self._path)
mpos = Vec2(*pygame.mouse.get_pos())
self.tooltip_nearest(mpos)
self.surf.blit(self._tooltip_surf, (0, 0))
def set_path(self, path: Path) -> None:
self._path = path
def tooltip_nearest(self, mpos: Vec2) -> None:
closest = None
closest_i = 0
min_dist = 0
for i, pt in enumerate(self._path.points):
pt = Vec2(*self.real_to_screen(pt.x, pt.y))
dist = (pt - mpos).mag
if i == 0 or dist < min_dist:
closest = pt
closest_i = i
min_dist = dist
pt = closest
speed = self._path.extra_data[closest_i]
col = (200, 150, 50)
pygame.draw.circle(self.surf, col, (pt.x, pt.y), 2 * self.PATH_WIDTH, 2)
pygame.draw.line(self.surf, col, (pt.x, pt.y), (mpos.x, mpos.y), 2)
txt = self.font.render(f"{speed:.1f} km/h", True, (150, 200, 255))
txt_size = Vec2(*txt.get_size())
txt_pos = mpos - txt_size.scale(Vec2(0.5, 1))
pygame.draw.rect(self._tooltip_surf, (0, 0, 0, 150), (txt_pos.x, txt_pos.y, txt_size.x, txt_size.y))
self._tooltip_surf.blit(txt, (txt_pos.x, txt_pos.y))
if __name__ == '__main__':
name = "data_28-03"
cities = [
(Vec2(7.359119, 46.227302), "Sion", "above"),
(Vec2(7.079001, 46.105981), "Martigny", "below"),
(Vec2(7.001849, 46.216559), "Saint-Maurice", "right")
]
data = GPSLoader.load_data(f"{name}.csv")
speeds = list(map(lambda s: s.convert(Unit.KM_H).value, data["speeds"]))
path = Path(data["points"], speeds)
pygame.init()
win = pygame.display.set_mode([600, 600])
display = SpeedMapDisplay(
win,
6.971094, 7.430611,
46.076312, 46.253036,
cities,
(255, 0, 0), (0, 255, 0), 155)
display.set_path(path)
display.mainloop()

109
units.py Normal file
View File

@ -0,0 +1,109 @@
from enum import Enum, auto
from math import pi
class UnitClass(Enum):
NONE = auto()
LENGTH = auto()
SPEED = auto()
TIME = auto()
ANGLE = auto()
class Unit(Enum):
KM_H = auto(), UnitClass.SPEED
M_S = auto(), UnitClass.SPEED
KM = auto(), UnitClass.LENGTH
M = auto(), UnitClass.LENGTH
SEC = auto(), UnitClass.TIME
MIN = auto(), UnitClass.TIME
HOUR = auto(), UnitClass.TIME
DEG = auto(), UnitClass.ANGLE
RAD = auto(), UnitClass.ANGLE
NONE = auto(), UnitClass.NONE
def __init__(self, v: int, cls: UnitClass):
self.cls: UnitClass = cls
class Value:
def __init__(self, value: float, unit: Unit):
self.value: float = value
self.unit: Unit = unit
def convert(self, to_unit: Unit) -> "Value":
if self.unit == to_unit:
return self
from_cls = self.unit.cls
to_cls = to_unit.cls
if from_cls != to_cls:
raise ValueError(f"Cannot convert from {from_cls} to {to_cls}")
cls = from_cls
if cls == UnitClass.NONE:
to_value = self.value
elif cls == UnitClass.LENGTH:
value = self.value
if self.unit == Unit.KM:
value *= 1000
to_value = value
if to_unit == Unit.KM:
to_value /= 1000
elif cls == UnitClass.TIME:
value = self.value
if self.unit == Unit.MIN:
value *= 60
elif self.unit == Unit.HOUR:
value *= 3600
to_value = value
if to_unit == Unit.MIN:
to_value /= 60
elif self.unit == Unit.HOUR:
to_value /= 3600
elif cls == UnitClass.SPEED:
value = self.value
if self.unit == Unit.KM_H:
value /= 3.6
to_value = value
if to_unit == Unit.KM_H:
to_value *= 3.6
elif cls == UnitClass.ANGLE:
value = self.value
if self.unit == Unit.RAD:
value *= 180 / pi
to_value = value
if to_unit == Unit.RAD:
value *= pi / 180
else:
raise ValueError(f"Unknown unit class: {cls}")
return Value(to_value, to_unit)
def __repr__(self):
return f"{self.value} [{self.unit}]"
if __name__ == '__main__':
v1 = Value(765, Unit.KM_H)
print(v1)
print(v1.convert(Unit.M_S))
v2 = Value(120, Unit.SEC)
print(v2)
print(v2.convert(Unit.MIN))

38
vec.py Normal file
View File

@ -0,0 +1,38 @@
from math import sqrt
class Vec2:
def __init__(self, x: float = 0, y: float = 0):
self.x: float = x
self.y: float = y
@property
def mag(self) -> float:
return sqrt(self.x ** 2 + self.y ** 2)
def normalized(self) -> "Vec2":
mag = self.mag
if mag == 0:
return Vec2()
return self / mag
def __add__(self, v: "Vec2") -> "Vec2":
return Vec2(self.x + v.x, self.y + v.y)
def __sub__(self, v: "Vec2") -> "Vec2":
return Vec2(self.x - v.x, self.y - v.y)
def __mul__(self, f: float) -> "Vec2":
return Vec2(self.x * f, self.y * f)
def __truediv__(self, f: float) -> "Vec2":
return Vec2(self.x / f, self.y / f)
def __neg__(self) -> "Vec2":
return Vec2(-self.x, -self.y)
def cross(self, v: "Vec2") -> float:
return self.x * v.y - self.y * v.x
def scale(self, v: "Vec2") -> "Vec2":
return Vec2(self.x * v.x, self.y * v.y)