@escapenavigator/utils 2.0.27 → 2.0.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/decode-escaped-html.d.ts +27 -0
- package/dist/decode-escaped-html.js +79 -0
- package/dist/decode-escaped-html.test.d.ts +6 -0
- package/dist/decode-escaped-html.test.js +60 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -2
- package/package.json +4 -4
- package/dist/city-quest-content.d.ts +0 -8
- package/dist/city-quest-content.js +0 -35
- package/dist/route-warnings.d.ts +0 -30
- package/dist/route-warnings.js +0 -117
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Часть сохранённых документов (`agbs.text`, клиентские договоры операторов)
|
|
3
|
+
* лежит в БД не как HTML, а как HTML-разметка, прогнанная через
|
|
4
|
+
* escape: `<p><h1>Datenschutzerklärung</h1></p>`. Так получается,
|
|
5
|
+
* когда готовый HTML вставляют в ReactQuill в визуальном режиме вместо режима
|
|
6
|
+
* raw HTML: редактор считает теги обычным текстом, экранирует их и
|
|
7
|
+
* дополнительно оборачивает каждую строку в свой `<p>`. На странице документа
|
|
8
|
+
* пользователь видит теги вместо форматирования.
|
|
9
|
+
*
|
|
10
|
+
* `decodeEscapedHtml` разворачивает такой контент обратно в разметку. Работает
|
|
11
|
+
* только при явном признаке — экранированном структурном теге (`<p>`,
|
|
12
|
+
* `<h2>`, `<li>` и т.п.); обычный текст со случайными `<`/`>`
|
|
13
|
+
* («цена < 50 €») остаётся как есть, потому что за ними не следует имя тега.
|
|
14
|
+
*
|
|
15
|
+
* Экранирование бывает многоуровневым — документ могли переоткрыть и
|
|
16
|
+
* пересохранить в том же визуальном режиме, тогда `<` превращается в
|
|
17
|
+
* `&lt;`. Поэтому декодируем в цикле, пока признак сохраняется, с лимитом
|
|
18
|
+
* `MAX_DECODE_PASSES` от зацикливания.
|
|
19
|
+
*
|
|
20
|
+
* XSS: функция только снимает escape, поэтому её результат ОБЯЗАН проходить
|
|
21
|
+
* через `sanitizeHtml` перед вставкой в DOM — как и любой другой HTML из БД.
|
|
22
|
+
*
|
|
23
|
+
* Чистая и идемпотентная: повторный вызов на уже развёрнутом HTML возвращает
|
|
24
|
+
* тот же результат.
|
|
25
|
+
*/
|
|
26
|
+
export declare const isEscapedHtml: (html: string | null | undefined) => boolean;
|
|
27
|
+
export declare function decodeEscapedHtml(html: string | null | undefined): string;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Часть сохранённых документов (`agbs.text`, клиентские договоры операторов)
|
|
4
|
+
* лежит в БД не как HTML, а как HTML-разметка, прогнанная через
|
|
5
|
+
* escape: `<p><h1>Datenschutzerklärung</h1></p>`. Так получается,
|
|
6
|
+
* когда готовый HTML вставляют в ReactQuill в визуальном режиме вместо режима
|
|
7
|
+
* raw HTML: редактор считает теги обычным текстом, экранирует их и
|
|
8
|
+
* дополнительно оборачивает каждую строку в свой `<p>`. На странице документа
|
|
9
|
+
* пользователь видит теги вместо форматирования.
|
|
10
|
+
*
|
|
11
|
+
* `decodeEscapedHtml` разворачивает такой контент обратно в разметку. Работает
|
|
12
|
+
* только при явном признаке — экранированном структурном теге (`<p>`,
|
|
13
|
+
* `<h2>`, `<li>` и т.п.); обычный текст со случайными `<`/`>`
|
|
14
|
+
* («цена < 50 €») остаётся как есть, потому что за ними не следует имя тега.
|
|
15
|
+
*
|
|
16
|
+
* Экранирование бывает многоуровневым — документ могли переоткрыть и
|
|
17
|
+
* пересохранить в том же визуальном режиме, тогда `<` превращается в
|
|
18
|
+
* `&lt;`. Поэтому декодируем в цикле, пока признак сохраняется, с лимитом
|
|
19
|
+
* `MAX_DECODE_PASSES` от зацикливания.
|
|
20
|
+
*
|
|
21
|
+
* XSS: функция только снимает escape, поэтому её результат ОБЯЗАН проходить
|
|
22
|
+
* через `sanitizeHtml` перед вставкой в DOM — как и любой другой HTML из БД.
|
|
23
|
+
*
|
|
24
|
+
* Чистая и идемпотентная: повторный вызов на уже развёрнутом HTML возвращает
|
|
25
|
+
* тот же результат.
|
|
26
|
+
*/
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.isEscapedHtml = void 0;
|
|
29
|
+
exports.decodeEscapedHtml = decodeEscapedHtml;
|
|
30
|
+
const MAX_DECODE_PASSES = 3;
|
|
31
|
+
/**
|
|
32
|
+
* Признак экранированной разметки: `<` + опциональный `/` + имя известного
|
|
33
|
+
* тега + граница (`>`, пробел или `/>` у самозакрывающихся). Требование
|
|
34
|
+
* границы отсекает ложные срабатывания на тексте вида `<br` внутри слова.
|
|
35
|
+
*
|
|
36
|
+
* `&(?:amp;)*` покрывает многоуровневое экранирование: `<`, `&lt;`,
|
|
37
|
+
* `&amp;lt;`. Без этого второй уровень не распознавался и цикл декодирования
|
|
38
|
+
* не запускался вовсе.
|
|
39
|
+
*/
|
|
40
|
+
const ESCAPED_TAG_PATTERN = /&(?:amp;)*lt;\/?(?:p|div|span|br|hr|h[1-6]|ul|ol|li|table|thead|tbody|tr|td|th|blockquote|pre|code|strong|em|b|i|u|s|a|img)(?:\s|\/?&(?:amp;)*gt;)/i;
|
|
41
|
+
const ENTITIES = {
|
|
42
|
+
'<': '<',
|
|
43
|
+
'>': '>',
|
|
44
|
+
'"': '"',
|
|
45
|
+
''': "'",
|
|
46
|
+
''': "'",
|
|
47
|
+
''': "'",
|
|
48
|
+
'&': '&',
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Снимает ровно один уровень escape. Один проход регуляркой важен для
|
|
52
|
+
* корректности: `&lt;` должен стать `<`, а не `<`. Сканер сдвигается
|
|
53
|
+
* за уже подставленный `&`, поэтому повторного декодирования в этом же
|
|
54
|
+
* проходе не происходит.
|
|
55
|
+
*/
|
|
56
|
+
const decodeOnce = (html) => html.replace(/&(?:lt|gt|quot|apos|amp|#39|#x27);/gi, (entity) => ENTITIES[entity.toLowerCase()] ?? entity);
|
|
57
|
+
/**
|
|
58
|
+
* Убирает Quill-обёртку `<p>` вокруг блочного элемента: после декодирования
|
|
59
|
+
* получается невалидная вложенность `<p><h2>…</h2></p>`. Браузер её всё равно
|
|
60
|
+
* расклеивает, но с пустым абзацем — лишний вертикальный отступ в документе.
|
|
61
|
+
*
|
|
62
|
+
* `<\/?` обязателен: Quill оборачивает каждую строку исходника отдельно,
|
|
63
|
+
* поэтому одиночный закрывающий тег тоже приезжает в своём `<p>` — как
|
|
64
|
+
* `<p></ul></p>` у многострочных списков.
|
|
65
|
+
*/
|
|
66
|
+
const unwrapBlockParagraphs = (html) => html.replace(/<p>\s*(<\/?(?:div|h[1-6]|ul|ol|li|table|thead|tbody|tr|td|th|blockquote|pre|hr|p)\b[\s\S]*?)\s*<\/p>/gi, '$1');
|
|
67
|
+
const isEscapedHtml = (html) => !!html && ESCAPED_TAG_PATTERN.test(html);
|
|
68
|
+
exports.isEscapedHtml = isEscapedHtml;
|
|
69
|
+
function decodeEscapedHtml(html) {
|
|
70
|
+
if (!html)
|
|
71
|
+
return '';
|
|
72
|
+
let result = html;
|
|
73
|
+
let pass = 0;
|
|
74
|
+
while (pass < MAX_DECODE_PASSES && ESCAPED_TAG_PATTERN.test(result)) {
|
|
75
|
+
result = decodeOnce(result);
|
|
76
|
+
pass += 1;
|
|
77
|
+
}
|
|
78
|
+
return pass === 0 ? result : unwrapBlockParagraphs(result);
|
|
79
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Регрессия на документы, сохранённые через визуальный режим ReactQuill:
|
|
4
|
+
* разметка попала в БД экранированной и рендерилась пользователю как текст
|
|
5
|
+
* с тегами (`privacy_policy/de`, `agreement/de`, `agreement/us`).
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
const decode_escaped_html_1 = require("./decode-escaped-html");
|
|
9
|
+
describe('isEscapedHtml', () => {
|
|
10
|
+
it('находит экранированный структурный тег', () => {
|
|
11
|
+
expect((0, decode_escaped_html_1.isEscapedHtml)('<p><h1>Datenschutzerklärung</h1></p>')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
it('не реагирует на нормальный HTML', () => {
|
|
14
|
+
expect((0, decode_escaped_html_1.isEscapedHtml)('<h1>Datenschutzerklärung</h1><p>Stand: Juli 2026</p>')).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
it('не реагирует на `<`/`>` в обычном тексте', () => {
|
|
17
|
+
expect((0, decode_escaped_html_1.isEscapedHtml)('<p>Gruppen < 4 Personen zahlen mehr > 20 €</p>')).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
it('не реагирует на пустой ввод', () => {
|
|
20
|
+
expect((0, decode_escaped_html_1.isEscapedHtml)('')).toBe(false);
|
|
21
|
+
expect((0, decode_escaped_html_1.isEscapedHtml)(null)).toBe(false);
|
|
22
|
+
expect((0, decode_escaped_html_1.isEscapedHtml)(undefined)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('decodeEscapedHtml', () => {
|
|
26
|
+
it('разворачивает экранированную разметку и снимает Quill-обёртку', () => {
|
|
27
|
+
const stored = '<p><h1>Datenschutzerklärung</h1></p>';
|
|
28
|
+
expect((0, decode_escaped_html_1.decodeEscapedHtml)(stored)).toBe('<h1>Datenschutzerklärung</h1>');
|
|
29
|
+
});
|
|
30
|
+
it('разворачивает списки', () => {
|
|
31
|
+
const stored = '<p><ul></p><p><li>unsere Websites</li></p><p></ul></p>';
|
|
32
|
+
expect((0, decode_escaped_html_1.decodeEscapedHtml)(stored)).toBe('<ul><li>unsere Websites</li></ul>');
|
|
33
|
+
});
|
|
34
|
+
it('сохраняет атрибуты и кавычки', () => {
|
|
35
|
+
const stored = '<p><a href="https://example.com">hier</a></p>';
|
|
36
|
+
expect((0, decode_escaped_html_1.decodeEscapedHtml)(stored)).toBe('<p><a href="https://example.com">hier</a></p>');
|
|
37
|
+
});
|
|
38
|
+
it('снимает многоуровневое экранирование', () => {
|
|
39
|
+
const stored = '<p>&lt;h2&gt;1. Verantwortlicher&lt;/h2&gt;</p>';
|
|
40
|
+
expect((0, decode_escaped_html_1.decodeEscapedHtml)(stored)).toBe('<h2>1. Verantwortlicher</h2>');
|
|
41
|
+
});
|
|
42
|
+
it('не трогает нормальный HTML', () => {
|
|
43
|
+
const html = '<h1>Privacy Policy</h1><p>Last updated: <em>July 2026</em></p>';
|
|
44
|
+
expect((0, decode_escaped_html_1.decodeEscapedHtml)(html)).toBe(html);
|
|
45
|
+
});
|
|
46
|
+
it('не декодирует `&` в обычном тексте', () => {
|
|
47
|
+
const html = '<p>Codeum UG & Co. — Preis < 50 €</p>';
|
|
48
|
+
expect((0, decode_escaped_html_1.decodeEscapedHtml)(html)).toBe(html);
|
|
49
|
+
});
|
|
50
|
+
it('идемпотентна', () => {
|
|
51
|
+
const stored = '<p><h2>5. Empfänger</h2></p><p><p>Hosting</p></p>';
|
|
52
|
+
const once = (0, decode_escaped_html_1.decodeEscapedHtml)(stored);
|
|
53
|
+
expect((0, decode_escaped_html_1.decodeEscapedHtml)(once)).toBe(once);
|
|
54
|
+
});
|
|
55
|
+
it('возвращает пустую строку на пустом вводе', () => {
|
|
56
|
+
expect((0, decode_escaped_html_1.decodeEscapedHtml)(null)).toBe('');
|
|
57
|
+
expect((0, decode_escaped_html_1.decodeEscapedHtml)(undefined)).toBe('');
|
|
58
|
+
expect((0, decode_escaped_html_1.decodeEscapedHtml)('')).toBe('');
|
|
59
|
+
});
|
|
60
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export * from './agreement-config';
|
|
2
2
|
export * from './booking-lead-time';
|
|
3
3
|
export * from './booking-page-url';
|
|
4
|
-
export * from './city-quest-content';
|
|
5
4
|
export * from './classify-api-error';
|
|
6
5
|
export * from './convert-hhmm-to-minutes';
|
|
7
6
|
export * from './convert-minutes-to-hhmm';
|
|
@@ -28,7 +27,6 @@ export * from './promocode-nominal-rules';
|
|
|
28
27
|
export * from './redirect';
|
|
29
28
|
export * from './role-chat-permissions';
|
|
30
29
|
export * from './role-export-permissions';
|
|
31
|
-
export * from './route-warnings';
|
|
32
30
|
export * from './serialize-record';
|
|
33
31
|
export * from './serialize-slot';
|
|
34
32
|
export * from './slot-representative-price';
|
package/dist/index.js
CHANGED
|
@@ -17,7 +17,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
17
17
|
__exportStar(require("./agreement-config"), exports);
|
|
18
18
|
__exportStar(require("./booking-lead-time"), exports);
|
|
19
19
|
__exportStar(require("./booking-page-url"), exports);
|
|
20
|
-
__exportStar(require("./city-quest-content"), exports);
|
|
21
20
|
__exportStar(require("./classify-api-error"), exports);
|
|
22
21
|
__exportStar(require("./convert-hhmm-to-minutes"), exports);
|
|
23
22
|
__exportStar(require("./convert-minutes-to-hhmm"), exports);
|
|
@@ -44,7 +43,6 @@ __exportStar(require("./promocode-nominal-rules"), exports);
|
|
|
44
43
|
__exportStar(require("./redirect"), exports);
|
|
45
44
|
__exportStar(require("./role-chat-permissions"), exports);
|
|
46
45
|
__exportStar(require("./role-export-permissions"), exports);
|
|
47
|
-
__exportStar(require("./route-warnings"), exports);
|
|
48
46
|
__exportStar(require("./serialize-record"), exports);
|
|
49
47
|
__exportStar(require("./serialize-slot"), exports);
|
|
50
48
|
__exportStar(require("./slot-representative-price"), exports);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escapenavigator/utils",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.29",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
"test": "jest"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@escapenavigator/types": "^2.0.
|
|
17
|
+
"@escapenavigator/types": "^2.0.27",
|
|
18
18
|
"axios": "^0.21.4",
|
|
19
19
|
"class-transformer": "^0.5.1",
|
|
20
20
|
"class-validator": "^0.13.2",
|
|
21
21
|
"i18next": "^21.6.4",
|
|
22
|
-
"isomorphic-dompurify": "
|
|
22
|
+
"isomorphic-dompurify": "3.18.0",
|
|
23
23
|
"libphonenumber-js": "^1.12.24",
|
|
24
24
|
"quill-delta": "^5.1.0"
|
|
25
25
|
},
|
|
@@ -29,5 +29,5 @@
|
|
|
29
29
|
"ts-jest": "^29.1.1",
|
|
30
30
|
"typescript": "^5.6"
|
|
31
31
|
},
|
|
32
|
-
"gitHead": "
|
|
32
|
+
"gitHead": "a3aa3e96ee5a47f3de07c99d982d7302e1e73edc"
|
|
33
33
|
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import type { CityQuestContent, CityQuestRouteEvent, CityQuestStop, LocalizedText } from '@escapenavigator/types/dist/city-quest/city-quest-content';
|
|
2
|
-
/** Patch one stop by id; returns new content reference for jsonb saves. */
|
|
3
|
-
export declare const patchQuestStop: (content: CityQuestContent, stopId: string, patch: Partial<CityQuestStop>) => CityQuestContent;
|
|
4
|
-
/** Patch one route event by id. */
|
|
5
|
-
export declare const patchQuestRouteEvent: (content: CityQuestContent, eventId: string, patch: Partial<CityQuestRouteEvent>) => CityQuestContent;
|
|
6
|
-
/** Add a simple message route event after a stop. */
|
|
7
|
-
export declare const addMessageRouteEvent: (content: CityQuestContent, triggerAfterStopId: string, text: LocalizedText, from?: string) => CityQuestContent;
|
|
8
|
-
export declare const removeQuestRouteEvent: (content: CityQuestContent, eventId: string) => CityQuestContent;
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.removeQuestRouteEvent = exports.addMessageRouteEvent = exports.patchQuestRouteEvent = exports.patchQuestStop = void 0;
|
|
4
|
-
/** Patch one stop by id; returns new content reference for jsonb saves. */
|
|
5
|
-
const patchQuestStop = (content, stopId, patch) => ({
|
|
6
|
-
...content,
|
|
7
|
-
stops: (content.stops || []).map((stop) => (stop.id === stopId ? { ...stop, ...patch } : stop)),
|
|
8
|
-
});
|
|
9
|
-
exports.patchQuestStop = patchQuestStop;
|
|
10
|
-
/** Patch one route event by id. */
|
|
11
|
-
const patchQuestRouteEvent = (content, eventId, patch) => ({
|
|
12
|
-
...content,
|
|
13
|
-
route_events: (content.route_events || []).map((event) => event.id === eventId ? { ...event, ...patch } : event),
|
|
14
|
-
});
|
|
15
|
-
exports.patchQuestRouteEvent = patchQuestRouteEvent;
|
|
16
|
-
/** Add a simple message route event after a stop. */
|
|
17
|
-
const addMessageRouteEvent = (content, triggerAfterStopId, text, from) => {
|
|
18
|
-
const id = `re_${Date.now().toString(36)}`;
|
|
19
|
-
const event = {
|
|
20
|
-
id,
|
|
21
|
-
trigger_after_stop: triggerAfterStopId,
|
|
22
|
-
type: 'message',
|
|
23
|
-
content: { from, text },
|
|
24
|
-
};
|
|
25
|
-
return {
|
|
26
|
-
...content,
|
|
27
|
-
route_events: [...(content.route_events || []), event],
|
|
28
|
-
};
|
|
29
|
-
};
|
|
30
|
-
exports.addMessageRouteEvent = addMessageRouteEvent;
|
|
31
|
-
const removeQuestRouteEvent = (content, eventId) => ({
|
|
32
|
-
...content,
|
|
33
|
-
route_events: (content.route_events || []).filter((e) => e.id !== eventId),
|
|
34
|
-
});
|
|
35
|
-
exports.removeQuestRouteEvent = removeQuestRouteEvent;
|
package/dist/route-warnings.d.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
export type RoutePoint = {
|
|
2
|
-
lat: number;
|
|
3
|
-
lng: number;
|
|
4
|
-
};
|
|
5
|
-
export type NeighborDistanceWarning = {
|
|
6
|
-
fromIndex: number;
|
|
7
|
-
toIndex: number;
|
|
8
|
-
distanceKm: number;
|
|
9
|
-
level: 'yellow' | 'red';
|
|
10
|
-
};
|
|
11
|
-
export type RouteWarningsResult = {
|
|
12
|
-
neighborWarnings: NeighborDistanceWarning[];
|
|
13
|
-
sequenceWarning: boolean;
|
|
14
|
-
totalDistanceKm: number;
|
|
15
|
-
/** Rough walking time in minutes (distance @ 4 km/h + 8 min per stop). */
|
|
16
|
-
estimatedMinutes: number;
|
|
17
|
-
nearestNeighborDistanceKm: number;
|
|
18
|
-
};
|
|
19
|
-
/** Haversine distance in kilometres. */
|
|
20
|
-
export declare const haversineKm: (a: RoutePoint, b: RoutePoint) => number;
|
|
21
|
-
/** Greedy nearest-neighbor tour length starting at index 0. */
|
|
22
|
-
export declare const nearestNeighborLengthKm: (points: RoutePoint[]) => number;
|
|
23
|
-
export declare const analyzeRoute: (points: RoutePoint[]) => RouteWarningsResult;
|
|
24
|
-
/** Soft guidance for story length (not blocking). */
|
|
25
|
-
export declare const ROUTE_SOFT_LIMITS: {
|
|
26
|
-
minSteps: number;
|
|
27
|
-
maxSteps: number;
|
|
28
|
-
minTotalKm: number;
|
|
29
|
-
maxTotalKm: number;
|
|
30
|
-
};
|
package/dist/route-warnings.js
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ROUTE_SOFT_LIMITS = exports.analyzeRoute = exports.nearestNeighborLengthKm = exports.haversineKm = void 0;
|
|
4
|
-
const EARTH_RADIUS_KM = 6371;
|
|
5
|
-
const WALK_KMH = 4;
|
|
6
|
-
const MINUTES_PER_STOP = 8;
|
|
7
|
-
const YELLOW_KM = 1;
|
|
8
|
-
const RED_KM = 2;
|
|
9
|
-
const SEQUENCE_RATIO = 1.25;
|
|
10
|
-
const TURNBACK_DEG = 135;
|
|
11
|
-
/** Haversine distance in kilometres. */
|
|
12
|
-
const haversineKm = (a, b) => {
|
|
13
|
-
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
14
|
-
const dLat = toRad(b.lat - a.lat);
|
|
15
|
-
const dLng = toRad(b.lng - a.lng);
|
|
16
|
-
const lat1 = toRad(a.lat);
|
|
17
|
-
const lat2 = toRad(b.lat);
|
|
18
|
-
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
|
19
|
-
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
20
|
-
};
|
|
21
|
-
exports.haversineKm = haversineKm;
|
|
22
|
-
const bearingDeg = (a, b) => {
|
|
23
|
-
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
24
|
-
const toDeg = (rad) => (rad * 180) / Math.PI;
|
|
25
|
-
const lat1 = toRad(a.lat);
|
|
26
|
-
const lat2 = toRad(b.lat);
|
|
27
|
-
const dLng = toRad(b.lng - a.lng);
|
|
28
|
-
const y = Math.sin(dLng) * Math.cos(lat2);
|
|
29
|
-
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
|
|
30
|
-
return (toDeg(Math.atan2(y, x)) + 360) % 360;
|
|
31
|
-
};
|
|
32
|
-
const angleDiffDeg = (a, b) => {
|
|
33
|
-
const d = Math.abs(a - b) % 360;
|
|
34
|
-
return d > 180 ? 360 - d : d;
|
|
35
|
-
};
|
|
36
|
-
/** Greedy nearest-neighbor tour length starting at index 0. */
|
|
37
|
-
const nearestNeighborLengthKm = (points) => {
|
|
38
|
-
if (points.length < 2)
|
|
39
|
-
return 0;
|
|
40
|
-
const remaining = new Set(points.map((_, i) => i));
|
|
41
|
-
let current = 0;
|
|
42
|
-
remaining.delete(0);
|
|
43
|
-
let total = 0;
|
|
44
|
-
while (remaining.size) {
|
|
45
|
-
let best = -1;
|
|
46
|
-
let bestDist = Infinity;
|
|
47
|
-
remaining.forEach((idx) => {
|
|
48
|
-
const d = (0, exports.haversineKm)(points[current], points[idx]);
|
|
49
|
-
if (d < bestDist) {
|
|
50
|
-
bestDist = d;
|
|
51
|
-
best = idx;
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
total += bestDist;
|
|
55
|
-
current = best;
|
|
56
|
-
remaining.delete(best);
|
|
57
|
-
}
|
|
58
|
-
return total;
|
|
59
|
-
};
|
|
60
|
-
exports.nearestNeighborLengthKm = nearestNeighborLengthKm;
|
|
61
|
-
const analyzeRoute = (points) => {
|
|
62
|
-
const neighborWarnings = [];
|
|
63
|
-
let totalDistanceKm = 0;
|
|
64
|
-
for (let i = 0; i < points.length - 1; i++) {
|
|
65
|
-
const distanceKm = (0, exports.haversineKm)(points[i], points[i + 1]);
|
|
66
|
-
totalDistanceKm += distanceKm;
|
|
67
|
-
if (distanceKm > RED_KM) {
|
|
68
|
-
neighborWarnings.push({
|
|
69
|
-
fromIndex: i,
|
|
70
|
-
toIndex: i + 1,
|
|
71
|
-
distanceKm,
|
|
72
|
-
level: 'red',
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
else if (distanceKm > YELLOW_KM) {
|
|
76
|
-
neighborWarnings.push({
|
|
77
|
-
fromIndex: i,
|
|
78
|
-
toIndex: i + 1,
|
|
79
|
-
distanceKm,
|
|
80
|
-
level: 'yellow',
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
const nearestNeighborDistanceKm = (0, exports.nearestNeighborLengthKm)(points);
|
|
85
|
-
let sequenceWarning = false;
|
|
86
|
-
if (points.length >= 3 && nearestNeighborDistanceKm > 0) {
|
|
87
|
-
if (totalDistanceKm / nearestNeighborDistanceKm >= SEQUENCE_RATIO) {
|
|
88
|
-
sequenceWarning = true;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
if (!sequenceWarning && points.length >= 3) {
|
|
92
|
-
for (let i = 0; i < points.length - 2; i++) {
|
|
93
|
-
const b1 = bearingDeg(points[i], points[i + 1]);
|
|
94
|
-
const b2 = bearingDeg(points[i + 1], points[i + 2]);
|
|
95
|
-
if (angleDiffDeg(b1, b2) > TURNBACK_DEG) {
|
|
96
|
-
sequenceWarning = true;
|
|
97
|
-
break;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
const estimatedMinutes = Math.round((totalDistanceKm / WALK_KMH) * 60 + points.length * MINUTES_PER_STOP);
|
|
102
|
-
return {
|
|
103
|
-
neighborWarnings,
|
|
104
|
-
sequenceWarning,
|
|
105
|
-
totalDistanceKm,
|
|
106
|
-
estimatedMinutes,
|
|
107
|
-
nearestNeighborDistanceKm,
|
|
108
|
-
};
|
|
109
|
-
};
|
|
110
|
-
exports.analyzeRoute = analyzeRoute;
|
|
111
|
-
/** Soft guidance for story length (not blocking). */
|
|
112
|
-
exports.ROUTE_SOFT_LIMITS = {
|
|
113
|
-
minSteps: 6,
|
|
114
|
-
maxSteps: 12,
|
|
115
|
-
minTotalKm: 2,
|
|
116
|
-
maxTotalKm: 4,
|
|
117
|
-
};
|