Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
27 changes: 27 additions & 0 deletions app/views/settings/gtt/_general.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,31 @@
min: 0, max: 15, step: 1) %>
<em class="info"><%= l(:text_geojson_precision_info) %></em>
</p>

<p>
<%= content_tag(:label, l(:label_distance_unit)) %>
<%= select_tag 'settings[distance_unit]',
options_for_select(
RedmineGtt::DistanceUnit::METERS_PER_UNIT.keys.map { |u|
[l(:"gtt_distance_unit_#{u}"), u]
},
RedmineGtt::DistanceUnit.current
) %>
<em class="info"><%= l(:text_distance_unit_info) %></em>
</p>

<p>
<%= content_tag(:label, l(:label_api_distance_unit)) %>
<%# reserved: the API contract is meters-only for now; the setting
exists so a later version can offer more units without a
settings migration %>
<%= select_tag 'settings[api_distance_unit]',
options_for_select(
RedmineGtt::DistanceUnit::API_UNITS.map { |u|
[l(:"gtt_distance_unit_#{u}"), u]
},
RedmineGtt::DistanceUnit.api_unit
) %>
<em class="info"><%= l(:text_api_distance_unit_info) %></em>
</p>
</fieldset>
9 changes: 9 additions & 0 deletions config/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ de:
gtt_hide_map_for_invalid_geom_info: Bitte bearbeiten Sie die Anfrage und stellen
Sie die Geometrie ein, um die Karte zu sehen.
label_default_measure_enabled: Zeige Steuerung zum Messen von Länge und Fläche
label_distance_unit: "Anzeigeeinheit für Entfernungen"
text_distance_unit_info: "Einheit für angezeigte und eingegebene Entfernungen (Distanzfilter, Distanzspalte, Beobachtungsradius)."
label_api_distance_unit: "API-Entfernungseinheit"
text_api_distance_unit_info: "Die REST-API verwendet in dieser Version immer Meter."
gtt_distance_unit_m: "Meter (m)"
gtt_distance_unit_km: "Kilometer (km)"
gtt_distance_unit_ft: "Fuß (ft)"
gtt_distance_unit_mi: "Meilen (mi)"
gtt_distance_unit_nm: "Seemeilen (nm)"
gtt_label_watch_nearby_legend: "Automatisch beobachten: Tickets in der Nähe"
gtt_label_watch_nearby: "Neue Tickets in der Nähe meines Standorts beobachten"
gtt_label_watch_nearby_radius: "Entfernung"
Expand Down
9 changes: 9 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ en:
text_geojson_precision_info: "Decimal places for coordinates in GeoJSON output (EPSG:4326). 6 is about 0.11 m; lower values shrink the payload."
gtt_hide_map_for_invalid_geom_info: "Please edit the issue and set the geometry
to see the map."
label_distance_unit: "Distance display unit"
text_distance_unit_info: "Unit for distances shown and entered in the UI (distance filter, distance column, watch radius)."
label_api_distance_unit: "API distance unit"
text_api_distance_unit_info: "The REST API always uses meters in this version."
gtt_distance_unit_m: "Meters (m)"
gtt_distance_unit_km: "Kilometers (km)"
gtt_distance_unit_ft: "Feet (ft)"
gtt_distance_unit_mi: "Miles (mi)"
gtt_distance_unit_nm: "Nautical miles (nm)"

gtt_label_watch_nearby_legend: "Auto watch: nearby issues"
gtt_label_watch_nearby: "Watch new issues near my location"
Expand Down
9 changes: 9 additions & 0 deletions config/locales/ja.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ ja:
label_geojson_precision: "GeoJSON座標の小数点桁数"
text_geojson_precision_info: "GeoJSON出力(EPSG:4326)の座標の小数点以下の桁数。6桁で約0.11m。小さくするとデータ量を削減できます。"
gtt_hide_map_for_invalid_geom_info: "地図を見るにはチケットを編集して位置情報を登録してください。"
label_distance_unit: "距離の表示単位"
text_distance_unit_info: "画面で表示・入力する距離の単位(距離フィルタ、距離カラム、ウォッチ半径)。"
label_api_distance_unit: "APIの距離単位"
text_api_distance_unit_info: "このバージョンではREST APIは常にメートルを使用します。"
gtt_distance_unit_m: "メートル (m)"
gtt_distance_unit_km: "キロメートル (km)"
gtt_distance_unit_ft: "フィート (ft)"
gtt_distance_unit_mi: "マイル (mi)"
gtt_distance_unit_nm: "海里 (nm)"

gtt_label_watch_nearby_legend: "自動ウォッチ: 近くのチケット"
gtt_label_watch_nearby: "登録した所在地の近くの新しいチケットをウォッチする"
Expand Down
4 changes: 3 additions & 1 deletion init.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@
'hide_map_for_invalid_geom' => false,
# Keep in sync with RedmineGtt::DEFAULT_GEOJSON_PRECISION (literal here to
# avoid autoloading the module during plugin registration).
'geojson_precision' => 6
'geojson_precision' => 6,
'distance_unit' => 'm',
'api_distance_unit' => 'm'
},
partial: 'settings/gtt/settings'
)
Expand Down
1 change: 1 addition & 0 deletions lib/redmine_gtt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def self.setup_controller_patches
RedmineGtt::Patches::ProjectsControllerPatch.apply
RedmineGtt::Patches::UsersControllerPatch.apply
RedmineGtt::Patches::IssuesHelperPatch.apply
RedmineGtt::Patches::QueriesHelperPatch.apply

[
IssuesController,
Expand Down
48 changes: 48 additions & 0 deletions lib/redmine_gtt/distance_unit.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

module RedmineGtt
# Converts between meters and the display unit configured in the plugin
# settings (#10). Meters are the internal unit everywhere: storage, SQL,
# the filter wire format and the REST API all stay metric; only what the
# user sees and types is converted.
module DistanceUnit

METERS_PER_UNIT = {
'm' => 1.0,
'km' => 1000.0,
'ft' => 0.3048,
'mi' => 1609.344,
'nm' => 1852.0
}.freeze

DEFAULT = 'm'

# Units the REST API may use. Reserved for future flexibility: the API
# contract is meters and only meters for now, but the setting exists so
# a later version can widen this list without a settings migration.
API_UNITS = %w(m).freeze

# The configured display unit, falling back to meters for anything
# unknown (empty setting, typo from a manually edited setting hash).
def self.current
unit = Setting.plugin_redmine_gtt['distance_unit'].to_s
METERS_PER_UNIT.key?(unit) ? unit : DEFAULT
end

# The unit used by the REST API. Clamped to meters regardless of the
# stored value while API_UNITS has a single entry.
def self.api_unit
unit = Setting.plugin_redmine_gtt['api_distance_unit'].to_s
API_UNITS.include?(unit) ? unit : DEFAULT
end

def self.from_meters(meters, unit = current)
meters.to_f / METERS_PER_UNIT.fetch(unit)
end

def self.to_meters(value, unit = current)
value.to_f * METERS_PER_UNIT.fetch(unit)
end

end
end
3 changes: 2 additions & 1 deletion lib/redmine_gtt/patches/issue_query_patch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ def available_columns
caption: :field_geom
)
columns << QueryColumn.new(:distance,
caption: :label_gtt_distance,
# header shows the configured display unit, e.g. "Distance (km)"
caption: -> { "#{l(:label_gtt_distance)} (#{DistanceUnit.current})" },
sortable: lambda{
lng, lat = find_center_point
distance_query lng, lat
Expand Down
38 changes: 38 additions & 0 deletions lib/redmine_gtt/patches/queries_helper_patch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
module RedmineGtt
module Patches

# Renders the distance column in the configured display unit (#10).
# The underlying value stays meters (it is what the query selects and
# what the REST API returns); only the rendered list/CSV cell converts.
module QueriesHelperPatch

def self.apply
QueriesHelper.prepend self unless QueriesHelper < self
end

def column_value(column, item, value)
if column.name == :distance && value.present?
gtt_format_distance(value)
else
super
end
end

def csv_value(column, object, value)
if column.name == :distance && value.present?
# mirror core's CSV float formatting incl. the decimal separator
gtt_format_distance(value).gsub('.', l(:general_csv_decimal_separator))
else
super
end
end

private

def gtt_format_distance(meters)
sprintf('%.2f', RedmineGtt::DistanceUnit.from_meters(meters))
end

end
end
end
35 changes: 35 additions & 0 deletions src/components/gtt-client/redmine/filters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';

import { METERS_PER_UNIT, metersToUnitValue } from './filters';

describe('METERS_PER_UNIT', () => {
it('mirrors the server-side unit table', () => {
expect(METERS_PER_UNIT).toEqual({
m: 1,
km: 1000,
ft: 0.3048,
mi: 1609.344,
nm: 1852,
});
});
});

describe('metersToUnitValue', () => {
it('converts meters into the unit', () => {
expect(metersToUnitValue('1500', METERS_PER_UNIT.km)).toBe('1.5');
expect(metersToUnitValue('1609.344', METERS_PER_UNIT.mi)).toBe('1');
expect(metersToUnitValue('1852', METERS_PER_UNIT.nm)).toBe('1');
expect(metersToUnitValue('25', METERS_PER_UNIT.m)).toBe('25');
});

it('trims float noise from round trips', () => {
// 25 km -> 25000 m -> back: must not display 24.999999999
expect(metersToUnitValue('25000', METERS_PER_UNIT.km)).toBe('25');
expect(metersToUnitValue('804672', METERS_PER_UNIT.mi)).toBe('500');
});

it('passes empty and non-numeric values through unchanged', () => {
expect(metersToUnitValue('', METERS_PER_UNIT.km)).toBe('');
expect(metersToUnitValue('abc', METERS_PER_UNIT.km)).toBe('abc');
});
});
83 changes: 79 additions & 4 deletions src/components/gtt-client/redmine/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,40 @@ import { transform, transformExtent } from 'ol/proj';
// practically never matches anything (#364).
const DEFAULT_DISTANCE_OPERATOR = '<=';

// Meters per display unit, mirroring RedmineGtt::DistanceUnit (#10). The
// wire format (submitted filter values) and the API stay meters; only what
// the user sees and types in the filter row is converted.
export const METERS_PER_UNIT: Record<string, number> = {
m: 1,
km: 1000,
ft: 0.3048,
mi: 1609.344,
nm: 1852,
};

function distanceUnit(): { unit: string; factor: number } {
let unit = 'm';
try {
const defaults = document.getElementById('gtt-defaults');
const settings = JSON.parse(defaults?.dataset.pluginSettings ?? '{}');
if (METERS_PER_UNIT[settings.distance_unit]) {
unit = settings.distance_unit;
}
} catch {
// stay on meters when the settings payload is missing or malformed
}
return { unit, factor: METERS_PER_UNIT[unit] };
}

export function metersToUnitValue(meters: string, factor: number): string {
const value = parseFloat(meters);
if (!isFinite(value)) {
return meters ?? '';
}
// toFixed keeps converted round-trips readable (no 24.999999997)
return String(parseFloat((value / factor).toFixed(6)));
}

/**
* Builds the filter row for the distance filter, mirroring the div-based
* markup of Redmine 6.x core's buildFilterRow (application-legacy.js).
Expand Down Expand Up @@ -39,10 +73,10 @@ export function buildDistanceFilterRow(operator: string, values: string[]): void
</div>
<div class="values">
<span style="display:none;">
<input type="text" name="v[${field}][]" id="values_${field}_1" size="14" class="value">
<input type="text" name="v[${field}][]" id="values_${field}_1" size="14" class="value"> ${distanceUnit().unit}
</span>
<span style="display:none;">
<input type="text" name="v[${field}][]" id="values_${field}_2" size="14" class="value">
<input type="text" name="v[${field}][]" id="values_${field}_2" size="14" class="value"> ${distanceUnit().unit}
</span>
<input type="hidden" name="v[${field}][]" id="values_${field}_3">
<input type="hidden" name="v[${field}][]" id="values_${field}_4">
Expand All @@ -52,6 +86,45 @@ export function buildDistanceFilterRow(operator: string, values: string[]): void

appendOperatorOptions(row, field, operator || DEFAULT_DISTANCE_OPERATOR, filterOptions['type']);
fillDistanceValues(row, values);
convertDistanceInputOnSubmit();
}

/**
* The distance bounds are displayed and edited in the configured unit but
* submitted in meters (#10). Converting just before submit keeps the wire
* format and saved queries metric. Core submits #query_form through
* jQuery ($('#query_form').submit()), which triggers jQuery-bound handlers
* but bypasses native listeners, so the hook binds via jQuery when present.
*/
function convertDistanceInputOnSubmit(): void {
const form = document.querySelector('#query_form') as HTMLFormElement | null;
if (!form || form.dataset.gttDistanceUnitHook) {
return;
}
form.dataset.gttDistanceUnitHook = '1';

const convert = () => {
const { factor } = distanceUnit();
if (factor === 1) {
return;
}
for (const n of [1, 2]) {
const input = document.querySelector(`#values_distance_${n}`) as HTMLInputElement | null;
if (input && !input.disabled && input.value.trim() !== '') {
const value = parseFloat(input.value);
if (isFinite(value)) {
// the server truncates to integer meters anyway
input.value = String(Math.round(value * factor));
}
Comment thread
dkastl marked this conversation as resolved.
}
}
};

if (typeof $ !== 'undefined') {
$(form).on('submit', convert);
} else {
form.addEventListener('submit', convert);
}
}

function appendOperatorOptions(row: HTMLElement, field: string, selectedOperator: string, filterType: string): void {
Expand All @@ -70,12 +143,14 @@ function appendOperatorOptions(row: HTMLElement, field: string, selectedOperator

function fillDistanceValues(row: HTMLElement, values: string[]): void {
const input = (n: number) => row.querySelector(`#values_distance_${n}`) as HTMLInputElement;
const { factor } = distanceUnit();

input(1).value = values[0] ?? '';
// submitted/saved values are meters; display them in the configured unit
input(1).value = metersToUnitValue(values[0] ?? '', factor);
let baseIdx = 1;
if (values.length === 2 || values.length === 4) {
// upper bound for the 'between' operator
input(2).value = values[1];
input(2).value = metersToUnitValue(values[1], factor);
baseIdx = 2;
}

Expand Down
Loading