diff --git a/css/80_app.css b/css/80_app.css index ac658a6f264..91692fd8fd5 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -1478,6 +1478,64 @@ button.preset-reset .label.flash-bg { fill: var(--text-color); } +/* Subtag category icons – same as remove/revert buttons in label row */ +.field-label .subtag-icons { + display: flex; + flex-flow: row nowrap; + flex: 0 0 auto; + align-items: stretch; +} +.field-label .subtag-icon { + display: inline-block; +} +.field-label .subtag-icon .icon { + width: 14px; + height: 14px; +} +.field-label .subtag-icon.active, +.field-label .tag-reference-button.active { + background-color: var(--active-bg-color); +} +.field-label .subtag-icon.active .icon, +.field-label .tag-reference-button.active .icon { + opacity: 1; +} + +/* Wrapper around all expanded subtag fields (grouping + rounded border) */ +.subtag-expanded-outer { + margin-top: 0; + margin-left: 0; + margin-right: 0; + margin-bottom: 10px; + padding: 8px 10px 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--fill-secondary); +} +.subtag-expanded-outer:last-child { + margin-bottom: 0; +} +/* One section per expanded category inside the outer wrapper */ +.subtag-expanded-wrap { + margin-top: 8px; +} +.subtag-expanded-wrap:first-child { + margin-top: 0; +} +/* Each row is a full preset field (wrap-form-field) – same as sidebar */ +.subtag-expanded-row { + margin-top: 6px; +} +.subtag-expanded-row:first-child { + margin-top: 0; +} +.subtag-expanded-outer .form-field { + margin-bottom: 0; +} +.subtag-expanded-outer .wrap-form-field:last-child .form-field { + margin-bottom: 0; +} + .field-label .modified-icon, .field-label .remove-icon, .field-label .remove-icon-multilingual { diff --git a/data/core.yaml b/data/core.yaml index b9ca583b7a6..493f7036f9f 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -770,6 +770,13 @@ en: add_fields: "Add field:" lock: suggestion: 'The "{label}" field is locked because there is a Wikidata tag. You can delete it or edit the tags in the "Tags" section.' + subtag: + check_date: Additional related tag – when this was last verified (so you can check it more easily). + note_desc: Additional related tags – notes or descriptions (so you can check them more easily). + source: Additional related tag – source of this value (so you can check it more easily). + conditional: Additional related tag – conditional value, e.g. weather or time (so you can check it more easily). + numeric: Tags with numeric postfix (e.g. 1, 2, 3) for this key (so you can check them more easily). + other: Other additional related tags (so you can check them more easily). display_name_addr: "{housenumber} {streetOrPlace}" display_name_addr_with_unit: "{unit}, {housenumber} {streetOrPlace}" display_name: diff --git a/modules/ui/entity_editor.js b/modules/ui/entity_editor.js index c1009eccd01..921ea10ea3f 100644 --- a/modules/ui/entity_editor.js +++ b/modules/ui/entity_editor.js @@ -29,6 +29,12 @@ export function uiEntityEditor(context) { var _newFeature; var _sections; + /** + * D3 selection of the inspector body container. Kept so the preset fields + * section can be re-rendered when a subtag category is expanded. + * @type {d3.Selection} + */ + let _bodySelection; function entityEditor(selection) { @@ -88,19 +94,28 @@ export function uiEntityEditor(context) { .merge(bodyEnter); if (!_sections) { + const presetFieldsSection = uiSectionPresetFields(context) + .on('change', changeTags) + .on('revert', revertTags) + .on('expandSubtag', function() { + if (_bodySelection) { + _bodySelection.call(presetFieldsSection.render); + } + }); _sections = [ uiSectionSelectionList(context), uiSectionFeatureType(context).on('choose', function(presets) { dispatch.call('choose', this, presets); }), uiSectionEntityIssues(context), - uiSectionPresetFields(context).on('change', changeTags).on('revert', revertTags), + presetFieldsSection, uiSectionRawTagEditor('raw-tag-editor', context).on('change', changeTags), uiSectionRawMemberEditor(context), uiSectionRawMembershipEditor(context) ]; } + _bodySelection = body; _sections.forEach(function(section) { if (section.entityIDs) { section.entityIDs(_entityIDs); diff --git a/modules/ui/field.js b/modules/ui/field.js index 6f1d75f0ff0..84202a2894f 100644 --- a/modules/ui/field.js +++ b/modules/ui/field.js @@ -11,6 +11,7 @@ import { uiFields } from './fields'; import { LANGUAGE_SUFFIX_REGEX } from './fields/localized'; import { uiTagReference } from './tag_reference'; import { utilRebind, utilUniqueDomId } from '../util'; +import { renderSubtagIcons, renderSubtagExpanded } from './field_subtag_icons'; export function uiField(context, presetField, entityIDs, options) { @@ -19,10 +20,11 @@ export function uiField(context, presetField, entityIDs, options) { wrap: true, remove: true, revert: true, - info: true + info: true, + showSubtagIcons: true }, options); - var dispatch = d3_dispatch('change', 'revert'); + var dispatch = d3_dispatch('change', 'revert', 'expandSubtag'); var field = Object.assign({}, presetField); // shallow copy field.domId = utilUniqueDomId('form-field-' + field.safeid); var _show = options.show; @@ -38,6 +40,11 @@ export function uiField(context, presetField, entityIDs, options) { } var _locked = false; + /** + * Keys of subtag categories whose editable blocks are expanded (multiple can be open). + * @type {Set} + */ + let _expandedSubtagCategories = new Set(); var _lockedTip = uiTooltip() .title(() => t.append('inspector.lock.suggestion', { label: field.title })) .placement('bottom'); @@ -166,6 +173,12 @@ export function uiField(context, presetField, entityIDs, options) { .append('span') .attr('class', 'label-textannotation'); + if (options.showSubtagIcons) { + labelEnter + .append('span') + .attr('class', 'subtag-icons'); + } + if (options.remove) { labelEnter .append('button') @@ -246,6 +259,19 @@ export function uiField(context, presetField, entityIDs, options) { } d.impl.tags(_tags); + + // Expandable sub-fields for editing subtags (top-level preset fields only) + if (options.showSubtagIcons) { + renderSubtagExpanded(selection, { + field, + _tags, + allKeys, + _expandedSubtagCategories, + entityIDs, + createFieldComponent: (config, eids, opts) => uiField(context, config, eids, opts), + dispatch + }); + } }); @@ -255,6 +281,23 @@ export function uiField(context, presetField, entityIDs, options) { .classed('present', tagsContainFieldKey()); + // Subtag icons: show category icons only on top-level preset fields + if (options.showSubtagIcons) { + renderSubtagIcons(container, { + field, + _tags, + allKeys, + _expandedSubtagCategories, + dispatch + }, function (categoryKey) { + if (_expandedSubtagCategories.has(categoryKey)) { + _expandedSubtagCategories.delete(categoryKey); + } else { + _expandedSubtagCategories.add(categoryKey); + } + }); + } + // show a tip and lock icon if the field is locked var annotation = container.selectAll('.field-label .label-textannotation'); var icon = annotation.selectAll('.icon') diff --git a/modules/ui/field_subtag_icons.js b/modules/ui/field_subtag_icons.js new file mode 100644 index 00000000000..069c4548138 --- /dev/null +++ b/modules/ui/field_subtag_icons.js @@ -0,0 +1,243 @@ +/** + * UI for preset field subtag icons (check_date, note/description, source, conditional, other) + * and the expandable block of editable sub-fields when an icon is clicked. + * @module ui/field_subtag_icons + */ + +import { select as d3_select } from 'd3-selection'; +import { t } from '../core/localizer'; +import { svgIcon } from '../svg/icon'; +import { uiTooltip } from './tooltip'; +import { makeSubtagPresetFieldConfig } from './field_subtag_preset_config'; +import { detectSubtags, formatSubtagTooltip } from '../util/subtags'; + +/** + * @typedef {Object} SubtagPair + * @property {string} key + * @property {string} value + */ + +/** + * @typedef {Object} SubtagCategoryDatum + * @property {string} key - Category key (e.g. 'check_date', 'note_desc') + * @property {SubtagPair[]} pairs + * @property {string} explanation - Localized explanation for tooltip + * @property {string} iconId - SVG icon href (e.g. '#fas-calendar-days') + */ + +/** + * @typedef {Object} SubtagContext + * @property {Object} field - Parent preset field object + * @property {Object} _tags - Combined entity tags + * @property {Function} allKeys - function(): string[] + * @property {Set} _expandedSubtagCategories - Keys of expanded category blocks + * @property {string[]} entityIDs - Entity IDs for the selection (for uiField) + * @property {Function} createFieldComponent - (config, entityIDs, options) => field component (avoids circular import) + * @property {d3.Dispatch} dispatch - Field dispatch (change, revert, expandSubtag) + */ + +/** Category key to icon ID mapping */ +const CATEGORY_ICONS = Object.freeze({ + check_date: '#fas-calendar-days', + note_desc: '#fas-comment', + source: '#iD-icon-out-link', + conditional: '#fas-code', + numeric: '#fas-hashtag', + other: '#iD-icon-more' +}); + +/** Category key to result property name */ +const CATEGORY_TO_RESULT_KEY = Object.freeze({ + check_date: 'checkDate', + note_desc: 'noteDesc', + source: 'source', + conditional: 'conditional', + numeric: 'numeric', + other: 'other' +}); + +/** + * Build the list of category data for the current field/tags (for icon data join). + * @param {{ checkDate: SubtagPair[], noteDesc: SubtagPair[], source: SubtagPair[], conditional: SubtagPair[], other: SubtagPair[] }} st - result of detectSubtags(field, tags, allKeysFn) + * @returns {SubtagCategoryDatum[]} + */ +export function buildSubtagCategoryData(st) { + const categories = []; + if (st.checkDate.length) { + categories.push({ + key: 'check_date', + pairs: st.checkDate, + explanation: t('inspector.subtag.check_date'), + iconId: CATEGORY_ICONS.check_date + }); + } + if (st.noteDesc.length) { + categories.push({ + key: 'note_desc', + pairs: st.noteDesc, + explanation: t('inspector.subtag.note_desc'), + iconId: CATEGORY_ICONS.note_desc + }); + } + if (st.source.length) { + categories.push({ + key: 'source', + pairs: st.source, + explanation: t('inspector.subtag.source'), + iconId: CATEGORY_ICONS.source + }); + } + if (st.conditional.length) { + categories.push({ + key: 'conditional', + pairs: st.conditional, + explanation: t('inspector.subtag.conditional'), + iconId: CATEGORY_ICONS.conditional + }); + } + if (st.numeric?.length) { + categories.push({ + key: 'numeric', + pairs: st.numeric, + explanation: t('inspector.subtag.numeric'), + iconId: CATEGORY_ICONS.numeric + }); + } + if (st.other.length) { + categories.push({ + key: 'other', + pairs: st.other, + explanation: t('inspector.subtag.other'), + iconId: CATEGORY_ICONS.other + }); + } + return categories; +} + +/** + * Render subtag category icons into the given container (`.field-label .subtag-icons`). + * @param {d3.Selection} container - selection containing `.form-field` (one element) + * @param {SubtagContext} context + * @param {(categoryKey: string) => void} setExpandedCategory - called with the category key to toggle expanded state + */ +export function renderSubtagIcons(container, context, setExpandedCategory) { + const { field, _tags, allKeys, _expandedSubtagCategories, dispatch } = context; + const st = detectSubtags(field, _tags, allKeys); + const subtagData = buildSubtagCategoryData(st); + + const subtagIcons = container.selectAll('.field-label .subtag-icons') + .selectAll('.subtag-icon') + .data(subtagData, (d) => d.key); + + subtagIcons.exit().remove(); + + const subtagIconEnter = subtagIcons.enter() + .append('button') + .attr('type', 'button') + .attr('class', (d) => `subtag-icon subtag-icon-${d.key}`); + + subtagIconEnter + .each(function () { + const btn = d3_select(this); + btn.call(uiTooltip() + .title(() => { + const d = btn.datum(); + return d ? formatSubtagTooltip(d.explanation, d.pairs, field.type === 'directionalCombo', field) : ''; + }) + .placement('top')); + }) + .on('click', (d3_event, d) => { + d3_event.preventDefault(); + d3_event.stopPropagation(); + setExpandedCategory(d.key); + dispatch.call('expandSubtag', field); + }) + .call((sel) => sel.each(function (d) { + d3_select(this).call(svgIcon(d.iconId)); + })); + + subtagIcons.merge(subtagIconEnter) + .classed('active', (d) => _expandedSubtagCategories.has(d.key)) + .on('click', (d3_event, d) => { + d3_event.preventDefault(); + d3_event.stopPropagation(); + setExpandedCategory(d.key); + dispatch.call('expandSubtag', field); + }) + .select('use') + .attr('xlink:href', (d) => d.iconId); +} + +/** + * Render the expanded block of sub-fields using the same preset field logic as the sidebar. + * Each row is a full uiField (text, textarea, or date with "set today") so they look like preset fields. + * @param {d3.Selection} selection - the form-field container (single element) + * @param {SubtagContext} context + */ +export function renderSubtagExpanded(selection, context) { + const { field, _tags, allKeys, _expandedSubtagCategories, entityIDs, createFieldComponent, dispatch } = context; + const expandedKeys = Array.from(_expandedSubtagCategories); + const hasExpanded = expandedKeys.length > 0; + + const outer = selection.selectAll('.subtag-expanded-outer') + .data(hasExpanded ? [0] : []); + + outer.exit().remove(); + + const outerEnter = outer.enter() + .append('div') + .attr('class', 'subtag-expanded-outer'); + + const outerMerge = outer.merge(outerEnter); + + const expandedWrap = outerMerge.selectAll('.subtag-expanded-wrap') + .data(expandedKeys, (d) => d); + + expandedWrap.exit().remove(); + + const wrapEnter = expandedWrap.enter() + .append('div') + .attr('class', 'subtag-expanded-wrap'); + + const wrapMerge = expandedWrap.merge(wrapEnter); + + wrapMerge.each(function (cat) { + const wrap = d3_select(this); + const st = detectSubtags(field, _tags, allKeys); + const resultKey = CATEGORY_TO_RESULT_KEY[cat] || cat; + const pairs = st[resultKey] || []; + + const rows = wrap.selectAll('.wrap-form-field.subtag-expanded-row') + .data(pairs, (p) => p.key); + + rows.exit().remove(); + + const rowEnter = rows.enter() + .append('div') + .attr('class', 'wrap-form-field subtag-expanded-row'); + + rowEnter.each(function (pair) { + const subfieldConfig = makeSubtagPresetFieldConfig(cat, pair, field); + const fieldComponent = createFieldComponent(subfieldConfig, entityIDs || [], { + wrap: true, + remove: false, + revert: false, + info: false, + showSubtagIcons: false + }); + fieldComponent.on('change', (_changedField, t, onInput) => { + dispatch.call('change', field, t, onInput); + }); + this._subtagFieldComponent = fieldComponent; + }); + + const rowMerge = rows.merge(rowEnter); + rowMerge.each(function () { + const comp = this._subtagFieldComponent; + if (comp) { + comp.tags(_tags); + d3_select(this).call(comp.render); + } + }); + }); +} diff --git a/modules/ui/field_subtag_preset_config.js b/modules/ui/field_subtag_preset_config.js new file mode 100644 index 00000000000..455fbd5bf88 --- /dev/null +++ b/modules/ui/field_subtag_preset_config.js @@ -0,0 +1,62 @@ +/** + * Preset-field config for subtag rows (check_date, note/description, source, etc.). + * Builds objects compatible with presetField so they can be rendered with uiField + * like regular preset fields (including the date field with "set today" button). + * @module ui/field_subtag_preset_config + */ + +import { presetField } from '../presets/field'; +import { utilSafeClassName } from '../util'; + +/** Field type per subtag category (matches preset field types: date, textarea, text) */ +const CATEGORY_FIELD_TYPE = Object.freeze({ + check_date: 'date', + note_desc: 'textarea', + source: 'text', + conditional: 'text', + numeric: 'text', + other: 'text' +}); + +/** + * Get display label for a subtag row (e.g. "Left", "Right", or the tag key). + * @param {import('./field_subtag_icons').SubtagPair} pair + * @param {Object} parentField - Parent preset field (for directionalCombo check) + * @returns {string} + */ +function getSubtagRowLabel(pair, parentField) { + if (parentField.type === 'directionalCombo') { + if (pair.key.indexOf(':left') !== -1) return 'Left'; + if (pair.key.indexOf(':right') !== -1) return 'Right'; + if (pair.key.indexOf(':both') !== -1) return 'Both'; + } + // numericCombo: show just the number (e.g. "1", "2") for keys like panoramax:1, panoramax:2 + const numericMatch = pair.key.match(/^.+:(\d+)$/); + if (numericMatch) return numericMatch[1]; + return pair.key; +} + +/** + * Build a preset-field-like config for one subtag row so it can be passed to uiField + * and rendered with the same logic as sidebar preset fields (text, textarea, date with set-today). + * @param {string} category - Subtag category key (check_date, note_desc, source, conditional, other) + * @param {import('./field_subtag_icons').SubtagPair} pair - { key, value } + * @param {Object} parentField - Parent preset field + * @returns {Object} Field object compatible with presetField (id, safeid, key, type, title(), label(), etc.) + */ +export function makeSubtagPresetFieldConfig(category, pair, parentField) { + const fieldType = CATEGORY_FIELD_TYPE[category] || 'text'; + const safeid = utilSafeClassName(pair.key); + const fieldId = 'subtag-' + safeid; + const displayLabel = getSubtagRowLabel(pair, parentField); + + const raw = { + key: pair.key, + type: fieldType, + overrideLabel: displayLabel + }; + + return presetField(fieldId, raw, {}); +} + +export { CATEGORY_FIELD_TYPE }; diff --git a/modules/ui/fields/input.js b/modules/ui/fields/input.js index e2bd6ec678a..e17d7171f34 100644 --- a/modules/ui/fields/input.js +++ b/modules/ui/fields/input.js @@ -309,7 +309,8 @@ export function uiFieldText(field, context) { const now = new Date(); const today = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().split('T')[0]; - if ((field.key === 'check_date' || field.key === 'survey:date') && date !== today) { + const isCheckDateKey = field.key === 'check_date' || field.key === 'survey:date' || field.key.startsWith('check_date:') || field.key.includes(':check_date'); + if (isCheckDateKey && date !== today) { wrap.selectAll('.date-set-today') .data([0]) .enter() diff --git a/modules/ui/sections/preset_fields.js b/modules/ui/sections/preset_fields.js index 3cb86d6dff0..a72b9be3c93 100644 --- a/modules/ui/sections/preset_fields.js +++ b/modules/ui/sections/preset_fields.js @@ -15,7 +15,7 @@ export function uiSectionPresetFields(context) { .label(() => t.append('inspector.fields')) .disclosureContent(renderDisclosureContent); - var dispatch = d3_dispatch('change', 'revert'); + var dispatch = d3_dispatch('change', 'revert', 'expandSubtag'); var formFields = uiFormFields(context); var _state; var _fieldsArr; @@ -105,6 +105,9 @@ export function uiSectionPresetFields(context) { }) .on('revert', function(keys) { dispatch.call('revert', field, keys); + }) + .on('expandSubtag', function() { + dispatch.call('expandSubtag', this); }); }); } diff --git a/modules/ui/tag_reference.js b/modules/ui/tag_reference.js index 411cad415ce..a824dbda763 100644 --- a/modules/ui/tag_reference.js +++ b/modules/ui/tag_reference.js @@ -121,6 +121,7 @@ export function uiTagReference(what) { .style('opacity', '1'); _showing = true; + _button.classed('active', true); _button.selectAll('svg.icon use').each(function() { var iconUse = d3_select(this); @@ -142,6 +143,7 @@ export function uiTagReference(what) { }); _showing = false; + _button.classed('active', false); _button.selectAll('svg.icon use').each(function() { var iconUse = d3_select(this); @@ -165,6 +167,7 @@ export function uiTagReference(what) { .merge(_button); _button + .classed('active', _showing) .on('click', function (d3_event) { d3_event.stopPropagation(); d3_event.preventDefault(); diff --git a/modules/util/index.js b/modules/util/index.js index 7451eaef15d..2f522d79b55 100644 --- a/modules/util/index.js +++ b/modules/util/index.js @@ -43,6 +43,7 @@ export { utilRebind } from './rebind'; export { utilSafeClassName } from './util'; export { utilSetTransform } from './util'; export { utilSessionMutex } from './session_mutex'; +export { detectSubtags, formatSubtagTooltip, getSubtagKeys } from './subtags'; export { utilStringQs } from './util'; export { utilTagDiff } from './util'; export { utilTagText } from './util'; diff --git a/modules/util/subtags.ts b/modules/util/subtags.ts new file mode 100644 index 00000000000..3f645105df0 --- /dev/null +++ b/modules/util/subtags.ts @@ -0,0 +1,196 @@ +// +// Detect related subtags for a preset field (check_date, note/description, source, conditional, numericCombo, other). +// Used to show category icons and tooltips in the field label row. +// + +/** Key-value pair for a single tag */ +export interface SubtagPair { + key: string; + value: string; +} + +/** Result of subtag detection per category */ +export interface SubtagResult { + checkDate: SubtagPair[]; + noteDesc: SubtagPair[]; + source: SubtagPair[]; + conditional: SubtagPair[]; + numeric: SubtagPair[]; + other: SubtagPair[]; +} + +/** Numeric postfix range for numericCombo-style tags (e.g. panoramax:1, panoramax:2, ...). */ +const NUMERIC_POSTFIX_MIN = 1; +const NUMERIC_POSTFIX_MAX = 10; + +/** Minimal preset field shape used for subtag detection */ +export interface PresetFieldLike { + key?: string; + keys?: string[]; + type?: string; +} + +/** Function that returns the field's main tag keys (from field.js allKeys()) */ +export type AllKeysFn = () => string[]; + +/** Known prefixes for "other" related tags (whitelist to avoid listing every random tag) */ +const OTHER_PREFIXES: readonly string[] = ['mapillary', 'cycleway', 'footway', 'path']; + +/** + * Get the list of tag keys to consider for subtag detection for this field. + * For simple fields: [field.key]. For multiCombo: [baseKey]. For directionalCombo: all keys. + */ +export function getSubtagKeys(field: PresetFieldLike, allKeysFn?: AllKeysFn): string[] { + if (field.type === 'multiCombo' && field.key) { + const baseKey = field.key.replace(/:$/, ''); + return [baseKey]; + } + if (field.type === 'directionalCombo' && (field.keys || field.key)) { + const keys = field.keys || (field.key ? [field.key] : []); + const withBoth = keys.slice(); + if (field.key) { + const baseKeyDir = field.key.replace(/:both$/, ''); + if (keys.indexOf(baseKeyDir) === -1) withBoth.push(baseKeyDir); + if (keys.indexOf(baseKeyDir + ':both') === -1) withBoth.push(baseKeyDir + ':both'); + } + return withBoth; + } + if (field.type === 'localized' && field.key) { + return [field.key]; + } + const keys = allKeysFn ? allKeysFn() : (field.keys || (field.key ? [field.key] : [])); + return Array.isArray(keys) ? keys : [keys]; +} + +/** + * Detect which subtag categories have matches and collect key=value for tooltips. + */ +export function detectSubtags( + field: PresetFieldLike, + tags: Record | null | undefined, + allKeysFn?: AllKeysFn +): SubtagResult { + const result: SubtagResult = { + checkDate: [], + noteDesc: [], + source: [], + conditional: [], + numeric: [], + other: [] + }; + + if (!field || !tags || typeof tags !== 'object') return result; + + const keysToConsider = getSubtagKeys(field, allKeysFn); + const matchedKeys = new Set(); + + const addCheckDate = (tagKey: string, value: string): void => { + if (tagKey === 'source:date') return; + result.checkDate.push({ key: tagKey, value }); + matchedKeys.add(tagKey); + }; + const addNoteDesc = (tagKey: string, value: string): void => { + result.noteDesc.push({ key: tagKey, value }); + matchedKeys.add(tagKey); + }; + const addSource = (tagKey: string, value: string): void => { + if (tagKey === 'source:date') return; + result.source.push({ key: tagKey, value }); + matchedKeys.add(tagKey); + }; + const addConditional = (tagKey: string, value: string): void => { + result.conditional.push({ key: tagKey, value }); + matchedKeys.add(tagKey); + }; + const addNumeric = (tagKey: string, value: string): void => { + result.numeric.push({ key: tagKey, value }); + matchedKeys.add(tagKey); + }; + const addOther = (tagKey: string, value: string): void => { + result.other.push({ key: tagKey, value }); + matchedKeys.add(tagKey); + }; + + for (const tagKey of Object.keys(tags)) { + const value = tags[tagKey]; + if (value === undefined || value === null) continue; + + for (const k of keysToConsider) { + if (tagKey === 'check_date:' + k || tagKey === k + ':check_date') { + addCheckDate(tagKey, value); + break; + } + if (tagKey === 'note:' + k || tagKey === 'description:' + k || tagKey === k + ':note' || tagKey === k + ':description') { + addNoteDesc(tagKey, value); + break; + } + if (tagKey === 'source:' + k || tagKey === k + ':source') { + if (tagKey !== 'source:date') addSource(tagKey, value); + break; + } + if (tagKey === k + ':conditional') { + addConditional(tagKey, value); + break; + } + // numericCombo: base:1, base:2, ... base:10 (e.g. panoramax:1, panoramax:2) + for (let n = NUMERIC_POSTFIX_MIN; n <= NUMERIC_POSTFIX_MAX; n++) { + if (tagKey === k + ':' + n) { + addNumeric(tagKey, value); + break; + } + } + if (matchedKeys.has(tagKey)) break; + } + + if (matchedKeys.has(tagKey)) continue; + let isOther = false; + for (const base of keysToConsider) { + if (tagKey === base) continue; + for (const prefix of OTHER_PREFIXES) { + if (tagKey === prefix + ':' + base) { + addOther(tagKey, value); + isOther = true; + break; + } + } + if (isOther) break; + if (tagKey.indexOf(base + ':') === 0) { + const suffix = tagKey.slice((base + ':').length); + if (suffix !== 'check_date' && suffix !== 'note' && suffix !== 'description' && suffix !== 'source' && suffix !== 'conditional') { + addOther(tagKey, value); + isOther = true; + } + } + } + } + + return result; +} + +/** + * Build tooltip body: "explanation \n key=value" (one key=value per line). + * @param groupBySide - for directionalCombo, prefix with "Left:", "Right:" etc. + */ +export function formatSubtagTooltip( + explanation: string, + pairs: SubtagPair[], + groupBySide: boolean, + field?: PresetFieldLike +): string { + if (!pairs || pairs.length === 0) return explanation; + const lines = [explanation]; + if (groupBySide && field?.keys && pairs.length > 1) { + for (const p of pairs) { + let label = ''; + if (p.key.indexOf(':left') !== -1) label = 'Left: '; + else if (p.key.indexOf(':right') !== -1) label = 'Right: '; + else if (p.key.indexOf(':both') !== -1) label = 'Both: '; + lines.push(label + p.key + '=' + (p.value || '')); + } + } else { + for (const p of pairs) { + lines.push(p.key + '=' + (p.value || '')); + } + } + return lines.join('\n'); +}