Merge pull request #200 from Freika/feature/miles

Miles
This commit is contained in:
Evgenii Burmakin 2024-09-06 22:28:17 +03:00 committed by GitHub
commit cb71a33623
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 197 additions and 70 deletions

View file

@ -1 +1 @@
0.13.2
0.13.3

View file

@ -5,3 +5,4 @@ DATABASE_NAME=dawarich_development
DATABASE_PORT=5432
REDIS_URL=redis://localhost:6379/1
PHOTON_API_HOST='photon.komoot.io'
DISTANCE_UNIT='mi'

View file

@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.13.3] — 2024-09-06
### Added
- Support for miles. To switch to miles, provide `DISTANCE_UNIT` environment variable with value `mi` in the `docker-compose.yml` file. Default value is `km`.
It's recommended to update your stats manually after changing the `DISTANCE_UNIT` environment variable. You can do this by clicking the "Update stats" button on the Stats page.
IMPORTANT⚠: All settings are still should be provided in meters. All calculations though will be converted to feets and miles if `DISTANCE_UNIT` is set to `mi`.
```diff
dawarich_app:
image: freikin/dawarich:latest
container_name: dawarich_app
environment:
APPLICATION_HOST: "localhost"
APPLICATION_PROTOCOL: "http"
APPLICATION_PORT: "3000"
TIME_ZONE: "UTC"
+ DISTANCE_UNIT: "mi"
dawarich_sidekiq:
image: freikin/dawarich:latest
container_name: dawarich_sidekiq
environment:
APPLICATION_HOST: "localhost"
APPLICATION_PROTOCOL: "http"
APPLICATION_PORT: "3000"
TIME_ZONE: "UTC"
+ DISTANCE_UNIT: "mi"
```
## [0.13.2] — 2024-09-06
### Fixed

View file

@ -35,7 +35,9 @@ class MapController < ApplicationController
@distance ||= 0
@coordinates.each_cons(2) do
@distance += Geocoder::Calculations.distance_between([_1[0], _1[1]], [_2[0], _2[1]], units: :km)
@distance += Geocoder::Calculations.distance_between(
[_1[0], _1[1]], [_2[0], _2[1]], units: DISTANCE_UNIT.to_sym
)
end
@distance.round(1)

View file

@ -50,7 +50,8 @@ module ApplicationHelper
"#{countries} countries, #{cities} cities"
end
def year_distance_stat_in_km(year, user)
def year_distance_stat(year, user)
# In km or miles, depending on the application settings (DISTANCE_UNIT)
Stat.year_distance(year, user).sum { _1[1] }
end
@ -81,7 +82,7 @@ module ApplicationHelper
def sidebar_distance(distance)
return unless distance
"#{distance} km"
"#{distance} #{DISTANCE_UNIT}"
end
def sidebar_points(points)

View file

@ -25,6 +25,7 @@ export default class extends Controller {
this.userSettings = JSON.parse(this.element.dataset.user_settings);
this.clearFogRadius = parseInt(this.userSettings.fog_of_war_meters) || 50;
this.routeOpacity = parseFloat(this.userSettings.route_opacity) || 0.6;
this.distanceUnit = this.element.dataset.distance_unit || "km";
this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111];
@ -134,6 +135,13 @@ export default class extends Controller {
createPopupContent(marker) {
const timezone = this.element.dataset.timezone;
if (this.distanceUnit === "mi") {
// convert marker[5] from km/h to mph
marker[5] = marker[5] * 0.621371;
// convert marker[3] from meters to feet
marker[3] = marker[3] * 3.28084;
}
return `
<b>Timestamp:</b> ${formatDate(marker[4], timezone)}<br>
<b>Latitude:</b> ${marker[0]}<br>
@ -276,9 +284,9 @@ export default class extends Controller {
<b>Start:</b> ${firstTimestamp}<br>
<b>End:</b> ${lastTimestamp}<br>
<b>Duration:</b> ${timeOnRoute}<br>
<b>Total Distance:</b> ${formatDistance(totalDistance)}<br>
<b>Total Distance:</b> ${formatDistance(totalDistance, this.distanceUnit)}<br>
`;
console.log(this.distanceUnit);
if (isDebugMode) {
const prevPoint = polylineCoordinates[0];
const nextPoint = polylineCoordinates[polylineCoordinates.length - 1];

View file

@ -1,9 +1,30 @@
// javascript/maps/helpers.js
export function formatDistance(distance) {
if (distance < 1000) {
return `${distance.toFixed(2)} meters`;
export function formatDistance(distance, unit = 'km') {
let smallUnit, bigUnit;
if (unit === 'mi') {
distance *= 0.621371; // Convert km to miles
smallUnit = 'ft';
bigUnit = 'mi';
// If the distance is less than 1 mile, return it in feet
if (distance < 1) {
distance *= 5280; // Convert miles to feet
return `${distance.toFixed(2)} ${smallUnit}`;
} else {
return `${distance.toFixed(2)} ${bigUnit}`;
}
} else {
return `${(distance / 1000).toFixed(2)} km`;
smallUnit = 'm';
bigUnit = 'km';
// If the distance is less than 1 km, return it in meters
if (distance < 1) {
distance *= 1000; // Convert km to meters
return `${distance.toFixed(2)} ${smallUnit}`;
} else {
return `${distance.toFixed(2)} ${bigUnit}`;
}
}
}
@ -37,9 +58,10 @@ export function formatDate(timestamp, timezone) {
return date.toLocaleString("en-GB", { timeZone: timezone });
}
export function haversineDistance(lat1, lon1, lat2, lon2) {
export function haversineDistance(lat1, lon1, lat2, lon2, unit = 'km') {
const toRad = (x) => (x * Math.PI) / 180;
const R = 6371; // Radius of the Earth in kilometers
const R_km = 6371; // Radius of the Earth in kilometers
const R_miles = 3959; // Radius of the Earth in miles
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
@ -47,5 +69,10 @@ export function haversineDistance(lat1, lon1, lat2, lon2) {
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c * 1000; // Distance in meters
if (unit === 'miles') {
return R_miles * c; // Distance in miles
} else {
return R_km * c; // Distance in kilometers
}
}

View file

@ -35,7 +35,8 @@ class User < ApplicationRecord
.compact
end
def total_km
def total_distance
# In km or miles, depending on the application settings (DISTANCE_UNIT)
stats.sum(:distance)
end

View file

@ -30,10 +30,15 @@ class Areas::Visits::Create
end
def area_points(area)
area_radius_in_km = area.radius / 1000.0
area_radius =
if DISTANCE_UNIT.to_sym == :km
area.radius / 1000.0
else
area.radius / 1609.344
end
points = Point.where(user_id: user.id)
.near([area.latitude, area.longitude], area_radius_in_km)
.near([area.latitude, area.longitude], area_radius, units: DISTANCE_UNIT.to_sym)
.order(timestamp: :asc)
# check if all points within the area are assigned to a visit

View file

@ -12,29 +12,12 @@ class CreateStats
def call
users.each do |user|
years.each do |year|
months.each do |month|
beginning_of_month_timestamp = DateTime.new(year, month).beginning_of_month.to_i
end_of_month_timestamp = DateTime.new(year, month).end_of_month.to_i
points = points(user, beginning_of_month_timestamp, end_of_month_timestamp)
next if points.empty?
stat = Stat.find_or_initialize_by(year:, month:, user:)
stat.distance = distance(points)
stat.toponyms = toponyms(points)
stat.daily_distance = stat.distance_by_day
stat.save
end
months.each { |month| update_month_stats(user, year, month) }
end
Notifications::Create.new(user:, kind: :info, title: 'Stats updated', content: 'Stats updated').call
create_stats_updated_notification(user)
rescue StandardError => e
Notifications::Create.new(
user:,
kind: :error,
title: 'Stats update failed',
content: "#{e.message}, stacktrace: #{e.backtrace.join("\n")}"
).call
create_stats_update_failed_notification(user, e)
end
end
@ -49,19 +32,49 @@ class CreateStats
.select(:latitude, :longitude, :timestamp, :city, :country)
end
def update_month_stats(user, year, month)
beginning_of_month_timestamp = DateTime.new(year, month).beginning_of_month.to_i
end_of_month_timestamp = DateTime.new(year, month).end_of_month.to_i
points = points(user, beginning_of_month_timestamp, end_of_month_timestamp)
return if points.empty?
stat = Stat.find_or_initialize_by(year:, month:, user:)
stat.distance = distance(points)
stat.toponyms = toponyms(points)
stat.daily_distance = stat.distance_by_day
stat.save
end
def distance(points)
km = 0
distance = 0
points.each_cons(2) do
km += Geocoder::Calculations.distance_between(
[_1.latitude, _1.longitude], [_2.latitude, _2.longitude], units: :km
distance += Geocoder::Calculations.distance_between(
[_1.latitude, _1.longitude], [_2.latitude, _2.longitude], units: DISTANCE_UNIT.to_sym
)
end
km
distance
end
def toponyms(points)
CountriesAndCities.new(points).call
end
def create_stats_updated_notification(user)
Notifications::Create.new(
user:, kind: :info, title: 'Stats updated', content: 'Stats updated'
).call
end
def create_stats_update_failed_notification(user, error)
Notifications::Create.new(
user:,
kind: :error,
title: 'Stats update failed',
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
).call
end
end

View file

@ -41,9 +41,10 @@
<div
class="w-full"
data-controller="maps"
data-distance_unit="<%= DISTANCE_UNIT %>"
data-api_key="<%= current_user.api_key %>"
data-user_settings=<%= current_user.settings.to_json %>
data-controller="maps"
data-coordinates="<%= @coordinates %>"
data-timezone="<%= Rails.configuration.time_zone %>">
<div data-maps-target="container" class="h-[25rem] w-auto min-h-screen">

View file

@ -5,7 +5,7 @@
<%= "#{Date::MONTHNAMES[stat.month]} of #{stat.year}" %>
<% end %>
</h2>
<p><%= stat.distance %>km</p>
<p><%= stat.distance %><%= DISTANCE_UNIT %></p>
<% if REVERSE_GEOCODING_ENABLED %>
<div class="card-actions justify-end">
<%= countries_and_cities_stat_for_month(stat) %>
@ -15,7 +15,7 @@
<%= column_chart(
stat.daily_distance,
height: '100px',
suffix: ' km',
suffix: " #{DISTANCE_UNIT}",
xtitle: 'Days',
ytitle: 'Distance'
) %>

View file

@ -6,7 +6,7 @@
<%= column_chart(
Stat.year_distance(year, current_user),
height: '200px',
suffix: ' km',
suffix: " #{DISTANCE_UNIT}",
xtitle: 'Days',
ytitle: 'Distance'
) %>

View file

@ -4,7 +4,7 @@
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
<div class="stat text-center">
<div class="stat-value text-primary">
<%= number_with_delimiter(current_user.total_km) %> km
<%= number_with_delimiter(current_user.total_distance) %> <%= DISTANCE_UNIT %>
</div>
<div class="stat-title">Total distance</div>
</div>
@ -32,8 +32,8 @@
<%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %>
</h2>
<p>
<% cache [current_user, 'year_distance_stat_in_km', year], skip_digest: true do %>
<%= number_with_delimiter year_distance_stat_in_km(year, current_user) %>km
<% cache [current_user, 'year_distance_stat', year], skip_digest: true do %>
<%= number_with_delimiter year_distance_stat(year, current_user) %><%= DISTANCE_UNIT %>
<% end %>
</p>
<% if REVERSE_GEOCODING_ENABLED %>
@ -44,7 +44,7 @@
<%= column_chart(
Stat.year_distance(year, current_user),
height: '200px',
suffix: ' km',
suffix: " #{DISTANCE_UNIT}",
xtitle: 'Days',
ytitle: 'Distance'
) %>

View file

@ -3,3 +3,4 @@
MIN_MINUTES_SPENT_IN_CITY = ENV.fetch('MIN_MINUTES_SPENT_IN_CITY', 60).to_i
REVERSE_GEOCODING_ENABLED = ENV.fetch('REVERSE_GEOCODING_ENABLED', 'true') == 'true'
PHOTON_API_HOST = ENV.fetch('PHOTON_API_HOST', nil)
DISTANCE_UNIT = ENV.fetch('DISTANCE_UNIT', 'km')

View file

@ -2,7 +2,7 @@
settings = {
timeout: 5,
units: :km,
units: DISTANCE_UNIT.to_sym,
cache: Redis.new,
always_raise: :all,
cache_options: {

View file

@ -48,6 +48,7 @@ services:
APPLICATION_HOSTS: localhost
TIME_ZONE: Europe/London
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
logging:
driver: "json-file"
options:
@ -85,6 +86,7 @@ services:
APPLICATION_HOSTS: localhost
BACKGROUND_PROCESSING_CONCURRENCY: 10
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
logging:
driver: "json-file"
options:

View file

@ -50,8 +50,8 @@ RSpec.describe User, type: :model do
end
end
describe '#total_km' do
subject { user.total_km }
describe '#total_distance' do
subject { user.total_distance }
let!(:stat1) { create(:stat, user:, distance: 10) }
let!(:stat2) { create(:stat, user:, distance: 20) }

View file

@ -21,32 +21,66 @@ RSpec.describe CreateStats do
let!(:point2) { create(:point, user:, import:, latitude: 1, longitude: 2) }
let!(:point3) { create(:point, user:, import:, latitude: 3, longitude: 4) }
it 'creates stats' do
expect { create_stats }.to change { Stat.count }.by(1)
end
it 'calculates distance' do
create_stats
expect(Stat.last.distance).to eq(563)
end
it 'created notifications' do
expect { create_stats }.to change { Notification.count }.by(1)
end
context 'when there is an error' do
before do
allow(Stat).to receive(:find_or_initialize_by).and_raise(StandardError)
context 'when units are kilometers' do
it 'creates stats' do
expect { create_stats }.to change { Stat.count }.by(1)
end
it 'does not create stats' do
expect { create_stats }.not_to(change { Stat.count })
it 'calculates distance' do
create_stats
expect(Stat.last.distance).to eq(563)
end
it 'created notifications' do
expect { create_stats }.to change { Notification.count }.by(1)
end
context 'when there is an error' do
before do
allow(Stat).to receive(:find_or_initialize_by).and_raise(StandardError)
end
it 'does not create stats' do
expect { create_stats }.not_to(change { Stat.count })
end
it 'created notifications' do
expect { create_stats }.to change { Notification.count }.by(1)
end
end
end
context 'when units are miles' do
before { stub_const('DISTANCE_UNIT', 'mi') }
it 'creates stats' do
expect { create_stats }.to change { Stat.count }.by(1)
end
it 'calculates distance' do
create_stats
expect(Stat.last.distance).to eq(349)
end
it 'created notifications' do
expect { create_stats }.to change { Notification.count }.by(1)
end
context 'when there is an error' do
before do
allow(Stat).to receive(:find_or_initialize_by).and_raise(StandardError)
end
it 'does not create stats' do
expect { create_stats }.not_to(change { Stat.count })
end
it 'created notifications' do
expect { create_stats }.to change { Notification.count }.by(1)
end
end
end
end
end