@jjlmoya/utils-nature 1.25.0 → 1.26.0

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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +2 -1
  4. package/src/index.ts +2 -0
  5. package/src/pages/[locale]/[slug].astro +5 -4
  6. package/src/tests/consumer_loader_contract.test.ts +24 -0
  7. package/src/tests/tool_validation.test.ts +2 -2
  8. package/src/tool/wildlifeCameraTrapEffortPlanner/bibliography.astro +9 -0
  9. package/src/tool/wildlifeCameraTrapEffortPlanner/bibliography.ts +16 -0
  10. package/src/tool/wildlifeCameraTrapEffortPlanner/component.astro +115 -0
  11. package/src/tool/wildlifeCameraTrapEffortPlanner/controller.ts +81 -0
  12. package/src/tool/wildlifeCameraTrapEffortPlanner/dom-views.ts +98 -0
  13. package/src/tool/wildlifeCameraTrapEffortPlanner/entry.ts +29 -0
  14. package/src/tool/wildlifeCameraTrapEffortPlanner/evaluator.test.ts +22 -0
  15. package/src/tool/wildlifeCameraTrapEffortPlanner/evaluator.ts +11 -0
  16. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/de.ts +49 -0
  17. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/en.ts +189 -0
  18. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/es.ts +46 -0
  19. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/fr.ts +44 -0
  20. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/id.ts +44 -0
  21. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/it.ts +43 -0
  22. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/ja.ts +43 -0
  23. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/ko.ts +43 -0
  24. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/nl.ts +43 -0
  25. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/pl.ts +43 -0
  26. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/pt.ts +43 -0
  27. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/ru.ts +43 -0
  28. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/sv.ts +43 -0
  29. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/tr.ts +43 -0
  30. package/src/tool/wildlifeCameraTrapEffortPlanner/i18n/zh.ts +43 -0
  31. package/src/tool/wildlifeCameraTrapEffortPlanner/index.ts +11 -0
  32. package/src/tool/wildlifeCameraTrapEffortPlanner/logic.test.ts +48 -0
  33. package/src/tool/wildlifeCameraTrapEffortPlanner/logic.ts +99 -0
  34. package/src/tool/wildlifeCameraTrapEffortPlanner/seo.astro +14 -0
  35. package/src/tool/wildlifeCameraTrapEffortPlanner/storage.ts +18 -0
  36. package/src/tool/wildlifeCameraTrapEffortPlanner/ui.ts +48 -0
  37. package/src/tool/wildlifeCameraTrapEffortPlanner/wildlife-camera-trap-effort-planner.css +442 -0
  38. package/src/tools.ts +3 -1
  39. package/src/types.ts +6 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-nature",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -5,10 +5,11 @@ import { rainHarvester } from '../tool/rainHarvester/entry';
5
5
  import { digitalCarbon } from '../tool/digitalCarbon/entry';
6
6
  import { urbanGardenPlanner } from '../tool/urbanGardenPlanner/entry';
7
7
  import { compostBinVolumeRatioCalculator } from '../tool/compostBinVolumeRatioCalculator/entry';
8
+ import { wildlifeCameraTrapEffortPlanner } from '../tool/wildlifeCameraTrapEffortPlanner/entry';
8
9
 
9
10
  export const natureCategory: NatureCategoryEntry = {
10
11
  icon: 'mdi:leaf',
11
- tools: [cricketThermometer, seedCalculator, rainHarvester, digitalCarbon, urbanGardenPlanner, compostBinVolumeRatioCalculator],
12
+ tools: [cricketThermometer, seedCalculator, rainHarvester, digitalCarbon, urbanGardenPlanner, compostBinVolumeRatioCalculator, wildlifeCameraTrapEffortPlanner],
12
13
  i18n: {
13
14
  en: () => import('./i18n/en').then((m) => m.content),
14
15
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -15,4 +15,5 @@ import { rainHarvester } from './tool/rainHarvester/entry';
15
15
  import { seedCalculator } from './tool/seedCalculator/entry';
16
16
  import { urbanGardenPlanner } from './tool/urbanGardenPlanner/entry';
17
17
  import { compostBinVolumeRatioCalculator } from './tool/compostBinVolumeRatioCalculator/entry';
18
- export const ALL_ENTRIES = [cricketThermometer, digitalCarbon, rainHarvester, seedCalculator, urbanGardenPlanner, compostBinVolumeRatioCalculator];
18
+ import { wildlifeCameraTrapEffortPlanner } from './tool/wildlifeCameraTrapEffortPlanner/entry';
19
+ export const ALL_ENTRIES = [cricketThermometer, digitalCarbon, rainHarvester, seedCalculator, urbanGardenPlanner, compostBinVolumeRatioCalculator, wildlifeCameraTrapEffortPlanner];
package/src/index.ts CHANGED
@@ -28,3 +28,5 @@ export { DIGITAL_CARBON_TOOL, digitalCarbon } from './tool/digitalCarbon/index';
28
28
  export { URBAN_GARDEN_PLANNER_TOOL, urbanGardenPlanner } from './tool/urbanGardenPlanner/index';
29
29
 
30
30
  export { COMPOST_BIN_VOLUME_RATIO_CALCULATOR_TOOL, compostBinVolumeRatioCalculator } from './tool/compostBinVolumeRatioCalculator/index';
31
+
32
+ export { WILDLIFE_CAMERA_TRAP_EFFORT_PLANNER_TOOL, wildlifeCameraTrapEffortPlanner } from './tool/wildlifeCameraTrapEffortPlanner/index';
@@ -8,14 +8,15 @@ import {
8
8
  Bibliography,
9
9
  SEORenderer,
10
10
  } from "@jjlmoya/utils-shared";
11
- import type { KnownLocale, ToolLocaleContent } from "../../types";
12
- import type { UtilitySEOContent } from "@jjlmoya/utils-shared";
11
+ import type { KnownLocale, ToolLocaleContent } from "../../types";
12
+ import type { UtilitySEOContent } from "@jjlmoya/utils-shared";
13
+ import type { AstroComponentFactory } from "astro/runtime/server/index.js";
13
14
 
14
15
  export async function getStaticPaths() {
15
16
  const paths = [];
16
17
 
17
18
  for (const { entry, Component: lazyComp } of ALL_TOOLS) {
18
- const Component = (await (lazyComp as () => Promise<{ default: unknown }>)()).default;
19
+ const Component = (await (lazyComp as () => Promise<{ default: AstroComponentFactory }>)()).default;
19
20
  const localeEntries = Object.entries(entry.i18n) as [
20
21
  KnownLocale,
21
22
  () => Promise<ToolLocaleContent>,
@@ -71,7 +72,7 @@ interface NavItem {
71
72
  }
72
73
 
73
74
  export interface Props {
74
- Component: unknown;
75
+ Component: AstroComponentFactory;
75
76
  locale: KnownLocale;
76
77
  content: ToolLocaleContent;
77
78
  localeUrls: Partial<Record<KnownLocale, string>>;
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ describe('Consumer loader contract', () => {
5
+ it('exposes renderable component loaders and locale content for both library layouts', async () => {
6
+ const tool = ALL_TOOLS.find(({ entry }) => entry.id === 'wildlife-camera-trap-effort-planner');
7
+ expect(tool).toBeDefined();
8
+ if (!tool) return;
9
+
10
+ const [component, seo, bibliography] = await Promise.all([
11
+ tool.Component(),
12
+ tool.SEOComponent(),
13
+ tool.BibliographyComponent(),
14
+ ]);
15
+ expect(typeof component.default).toBe('function');
16
+ expect(typeof seo.default).toBe('function');
17
+ expect(typeof bibliography.default).toBe('function');
18
+
19
+ const content = await tool.entry.i18n.es!();
20
+ expect((content.ui as { areaPerCameraLabel: string }).areaPerCameraLabel).toBe('Área por cámara');
21
+ expect(content.seo).toHaveLength(10);
22
+ expect(content.bibliography.length).toBeGreaterThan(0);
23
+ });
24
+ });
@@ -3,8 +3,8 @@ import { ALL_TOOLS, natureCategory } from '../index';
3
3
 
4
4
  describe('Tool Validation Suite', () => {
5
5
  describe('Library Registration', () => {
6
- it('should have 6 tools in ALL_TOOLS', () => {
7
- expect(ALL_TOOLS.length).toBe(6);
6
+ it('should have 7 tools in ALL_TOOLS', () => {
7
+ expect(ALL_TOOLS.length).toBe(7);
8
8
  });
9
9
 
10
10
  it('natureCategory should be defined', () => {
@@ -0,0 +1,9 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { wildlifeCameraTrapEffortPlanner } from './index';
4
+
5
+ const { locale = 'en' } = Astro.props;
6
+ const content = await wildlifeCameraTrapEffortPlanner.i18n[locale as keyof typeof wildlifeCameraTrapEffortPlanner.i18n]?.();
7
+ ---
8
+
9
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Ministerio para la Transición Ecológica y el Reto Demográfico. Prescripciones Técnicas nº 8, Ficha 9: Cámaras trampa',
6
+ url: 'https://www.miteco.gob.es/content/dam/miteco/es/biodiversidad/temas/ecosistemas-y-conectividad/prescripcionestecnicasn8_tcm30-521531.pdf',
7
+ },
8
+ {
9
+ name: 'Arthur Rylah Institute. Camera trapping',
10
+ url: 'https://www.ari.vic.gov.au/research/technology-and-skills/monitoring-techniques/camera-trapping',
11
+ },
12
+ {
13
+ name: 'Province of British Columbia. Wildlife Camera Metadata Protocol Version 1.0',
14
+ url: 'https://www2.gov.bc.ca/assets/gov/environment/natural-resource-stewardship/nr-laws-policy/risc/wcmp_v1.pdf',
15
+ },
16
+ ];
@@ -0,0 +1,115 @@
1
+ ---
2
+ import { wildlifeCameraTrapEffortPlanner } from './index';
3
+ import { DEFAULT_INPUTS } from './logic';
4
+
5
+ const { ui: propUi, locale = 'en' } = Astro.props;
6
+ let ui = propUi;
7
+
8
+ if (!ui) {
9
+ const content = await wildlifeCameraTrapEffortPlanner.i18n[locale as keyof typeof wildlifeCameraTrapEffortPlanner.i18n]?.();
10
+ ui = content?.ui;
11
+ }
12
+
13
+ if (!ui) return null;
14
+
15
+ const config = JSON.stringify({ ui, defaults: DEFAULT_INPUTS });
16
+ ---
17
+
18
+ <wildlife-camera-trap-effort-planner class="wctep-root">
19
+ <div class="wctep-shell">
20
+ <section class="wctep-controls" aria-label={ui.labelSurveyArea}>
21
+ <div class="wctep-control-topline">
22
+ <span class="wctep-mark" aria-hidden="true">FIELD / 01</span>
23
+ <div class="wctep-unit-switch" role="group" aria-label={ui.unitSystemLabel}>
24
+ <button type="button" data-unit-button="metric" aria-pressed="true">{ui.metricLabel}</button>
25
+ <button type="button" data-unit-button="imperial" aria-pressed="false">{ui.imperialLabel}</button>
26
+ </div>
27
+ </div>
28
+
29
+ <div class="wctep-fields">
30
+ <label class="wctep-field" for="wctep-area">
31
+ <span class="wctep-label">{ui.labelSurveyArea}</span>
32
+ <span class="wctep-input-line"><input id="wctep-area" data-input="area" type="number" min="0" step="0.1" inputmode="decimal" /><span data-unit-label="area">{ui.unitHectares}</span></span>
33
+ <span class="wctep-help">{ui.helpSurveyArea}</span>
34
+ </label>
35
+ <label class="wctep-field" for="wctep-cameras">
36
+ <span class="wctep-label">{ui.labelCameraCount}</span>
37
+ <span class="wctep-input-line"><input id="wctep-cameras" data-input="cameras" type="number" min="0" step="1" inputmode="numeric" /><span>{ui.unitCameras}</span></span>
38
+ <span class="wctep-help">{ui.helpCameraCount}</span>
39
+ </label>
40
+ <label class="wctep-field" for="wctep-days">
41
+ <span class="wctep-label">{ui.labelDeploymentDays}</span>
42
+ <span class="wctep-input-line"><input id="wctep-days" data-input="days" type="number" min="0" step="1" inputmode="numeric" /><span>{ui.unitDays}</span></span>
43
+ <span class="wctep-help">{ui.helpDeploymentDays}</span>
44
+ </label>
45
+ <label class="wctep-field" for="wctep-spacing">
46
+ <span class="wctep-label">{ui.labelSpacing}</span>
47
+ <span class="wctep-input-line"><input id="wctep-spacing" data-input="spacing" type="number" min="0" step="1" inputmode="decimal" /><span data-unit-label="spacing">{ui.unitMeters}</span></span>
48
+ <span class="wctep-help">{ui.helpSpacing}</span>
49
+ </label>
50
+ <label class="wctep-field wctep-field-wide" for="wctep-triggers">
51
+ <span class="wctep-label">{ui.labelTriggerRate}</span>
52
+ <span class="wctep-input-line"><input id="wctep-triggers" data-input="triggers" type="number" min="0" step="0.1" inputmode="decimal" /><span>{ui.unitEventsPerDay}</span></span>
53
+ <span class="wctep-help">{ui.helpTriggerRate}</span>
54
+ </label>
55
+ </div>
56
+
57
+ <div class="wctep-presets">
58
+ <span class="wctep-section-label">{ui.presetsTitle}</span>
59
+ <div class="wctep-preset-row">
60
+ <button type="button" data-preset="backyard">{ui.presetBackyard}</button>
61
+ <button type="button" data-preset="woodland">{ui.presetWoodland}</button>
62
+ <button type="button" data-preset="reserve">{ui.presetReserve}</button>
63
+ </div>
64
+ </div>
65
+ </section>
66
+
67
+ <section class="wctep-reading" aria-label={ui.mapLabel}>
68
+ <div class="wctep-map-column">
69
+ <div class="wctep-reading-head">
70
+ <span class="wctep-section-label">{ui.mapLabel}</span>
71
+ <span class="wctep-status" data-status aria-live="polite">{ui.statusBalanced}</span>
72
+ </div>
73
+ <div class="wctep-map" data-field-map role="img" aria-label={ui.mapLabel}></div>
74
+ <div class="wctep-map-key"><span class="wctep-key-station" aria-hidden="true"></span><span>{ui.stationLabel}</span><span class="wctep-key-cell" aria-hidden="true"></span><span>{ui.spacingLabel}</span><strong data-footprint>0%</strong></div>
75
+ <p class="wctep-caption">{ui.mapCaption}</p>
76
+ </div>
77
+
78
+ <div class="wctep-metrics" aria-live="polite">
79
+ <div class="wctep-metric wctep-metric-major"><span>{ui.cameraDaysLabel}</span><strong data-camera-days>0</strong></div>
80
+ <div class="wctep-metric"><span>{ui.expectedEventsLabel}</span><strong data-expected-events>0</strong></div>
81
+ <div class="wctep-metric"><span>{ui.spacingLabel}</span><strong data-grid-spacing>0 m</strong></div>
82
+ <div class="wctep-metric"><span>{ui.reviewLoadLabel}</span><strong data-review-load>0</strong></div>
83
+ <div class="wctep-metric wctep-metric-wide"><span>{ui.areaPerCameraLabel}</span><strong data-area-per-camera>0 m²</strong></div>
84
+ </div>
85
+ </section>
86
+
87
+ <section class="wctep-review">
88
+ <div class="wctep-review-copy"><span class="wctep-section-label">{ui.reviewScheduleTitle}</span><p>{ui.reviewScheduleText}</p></div>
89
+ <ol data-review-list>
90
+ <li><strong>{ui.reviewDayLabel} 7</strong><span>336 {ui.reviewEventsLabel}</span></li>
91
+ <li><strong>{ui.reviewDayLabel} 14</strong><span>672 {ui.reviewEventsLabel}</span></li>
92
+ <li><strong>{ui.reviewDayLabel} 21</strong><span>1008 {ui.reviewEventsLabel}</span></li>
93
+ <li><strong>{ui.reviewDayLabel} 28</strong><span>1344 {ui.reviewEventsLabel}</span></li>
94
+ </ol>
95
+ </section>
96
+
97
+ <section class="wctep-notes">
98
+ <div><span class="wctep-note-label">{ui.assumptionTitle}</span><p>{ui.assumptionText}</p></div>
99
+ <div><span class="wctep-note-label">{ui.limitationTitle}</span><p>{ui.limitationText}</p></div>
100
+ <p class="wctep-status-note" data-status-note>{ui.statusNote}</p>
101
+ </section>
102
+ </div>
103
+ </wildlife-camera-trap-effort-planner>
104
+
105
+ <script is:inline type="application/json" id="wctep-config" set:html={config}></script>
106
+
107
+ <script>
108
+ import { mountWildlifeCameraTrapEffortPlanner } from './controller';
109
+ const root = document.querySelector<HTMLElement>('wildlife-camera-trap-effort-planner');
110
+ const configNode = document.getElementById('wctep-config');
111
+ if (root && configNode) {
112
+ const config = JSON.parse(configNode.textContent || '{}');
113
+ mountWildlifeCameraTrapEffortPlanner(root, config);
114
+ }
115
+ </script>
@@ -0,0 +1,81 @@
1
+ import { ACRES_PER_HECTARE, calculateSurveyPlan, DEFAULT_INPUTS, METERS_PER_FOOT, type SurveyInput } from './logic';
2
+ import { formatInputValue, renderPlan } from './dom-views';
3
+ import { loadUnitSystem, saveUnitSystem, type UnitSystem } from './storage';
4
+ import type { WildlifeCameraTrapEffortPlannerUI } from './ui';
5
+
6
+ interface ToolConfig {
7
+ ui: WildlifeCameraTrapEffortPlannerUI;
8
+ defaults: SurveyInput;
9
+ }
10
+
11
+ const PRESETS: Record<string, SurveyInput> = {
12
+ backyard: { areaHectares: 1, cameraCount: 3, deploymentDays: 14, spacingMeters: 55, triggerRatePerCameraDay: 2 },
13
+ woodland: { areaHectares: 12, cameraCount: 12, deploymentDays: 28, spacingMeters: 100, triggerRatePerCameraDay: 4 },
14
+ reserve: { areaHectares: 80, cameraCount: 24, deploymentDays: 42, spacingMeters: 180, triggerRatePerCameraDay: 1.5 },
15
+ };
16
+
17
+ function input(root: HTMLElement, name: string): HTMLInputElement {
18
+ return root.querySelector<HTMLInputElement>(`[data-input="${name}"]`)!;
19
+ }
20
+
21
+ function readInput(root: HTMLElement, unitSystem: UnitSystem): SurveyInput {
22
+ const area = Number(input(root, 'area').value) || 0;
23
+ const spacing = Number(input(root, 'spacing').value) || 0;
24
+ return {
25
+ areaHectares: unitSystem === 'metric' ? area : area / ACRES_PER_HECTARE,
26
+ cameraCount: Number(input(root, 'cameras').value) || 0,
27
+ deploymentDays: Number(input(root, 'days').value) || 0,
28
+ spacingMeters: unitSystem === 'metric' ? spacing : spacing * METERS_PER_FOOT,
29
+ triggerRatePerCameraDay: Number(input(root, 'triggers').value) || 0,
30
+ };
31
+ }
32
+
33
+ function writeInput(root: HTMLElement, values: SurveyInput, unitSystem: UnitSystem): void {
34
+ input(root, 'area').value = formatInputValue(values.areaHectares, unitSystem, 'area');
35
+ input(root, 'cameras').value = String(values.cameraCount);
36
+ input(root, 'days').value = String(values.deploymentDays);
37
+ input(root, 'spacing').value = formatInputValue(values.spacingMeters, unitSystem, 'spacing');
38
+ input(root, 'triggers').value = String(values.triggerRatePerCameraDay);
39
+ }
40
+
41
+ function updateUnitLabels(root: HTMLElement, ui: WildlifeCameraTrapEffortPlannerUI, unitSystem: UnitSystem): void {
42
+ root.querySelector('[data-unit-label="area"]')!.textContent = unitSystem === 'metric' ? ui.unitHectares : ui.unitAcres;
43
+ root.querySelector('[data-unit-label="spacing"]')!.textContent = unitSystem === 'metric' ? ui.unitMeters : ui.unitFeet;
44
+ root.querySelector('[data-unit-button="metric"]')!.setAttribute('aria-pressed', String(unitSystem === 'metric'));
45
+ root.querySelector('[data-unit-button="imperial"]')!.setAttribute('aria-pressed', String(unitSystem === 'imperial'));
46
+ }
47
+
48
+ function render(root: HTMLElement, ui: WildlifeCameraTrapEffortPlannerUI, unitSystem: UnitSystem, values: SurveyInput): void {
49
+ renderPlan(root, calculateSurveyPlan(values), ui, unitSystem);
50
+ }
51
+
52
+ export function mountWildlifeCameraTrapEffortPlanner(root: HTMLElement, config: ToolConfig): void {
53
+ if (root.dataset.mounted === 'true') return;
54
+ root.dataset.mounted = 'true';
55
+ const ui = config.ui;
56
+ let unitSystem = loadUnitSystem();
57
+ let physicalValues = config.defaults ?? DEFAULT_INPUTS;
58
+ writeInput(root, physicalValues, unitSystem);
59
+ updateUnitLabels(root, ui, unitSystem);
60
+ root.querySelectorAll<HTMLInputElement>('[data-input]').forEach((field) => field.addEventListener('input', () => {
61
+ physicalValues = readInput(root, unitSystem);
62
+ render(root, ui, unitSystem, physicalValues);
63
+ }));
64
+ root.querySelectorAll<HTMLButtonElement>('[data-unit-button]').forEach((button) => button.addEventListener('click', () => {
65
+ const nextUnit = button.dataset.unitButton as UnitSystem;
66
+ if (nextUnit === unitSystem) return;
67
+ unitSystem = nextUnit;
68
+ writeInput(root, physicalValues, unitSystem);
69
+ updateUnitLabels(root, ui, unitSystem);
70
+ saveUnitSystem(unitSystem);
71
+ render(root, ui, unitSystem, physicalValues);
72
+ }));
73
+ root.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => button.addEventListener('click', () => {
74
+ const preset = PRESETS[button.dataset.preset ?? ''];
75
+ if (!preset) return;
76
+ physicalValues = preset;
77
+ writeInput(root, physicalValues, unitSystem);
78
+ render(root, ui, unitSystem, physicalValues);
79
+ }));
80
+ render(root, ui, unitSystem, physicalValues);
81
+ }
@@ -0,0 +1,98 @@
1
+ import type { WildlifeCameraTrapEffortPlannerUI } from './ui';
2
+ import { ACRES_PER_HECTARE, M2_PER_ACRE, METERS_PER_FOOT, type SurveyPlanResult } from './logic';
3
+ import { evaluatePlan } from './evaluator';
4
+ import type { UnitSystem } from './storage';
5
+
6
+ function number(value: number, maximumFractionDigits = 0): string {
7
+ return new Intl.NumberFormat('en-US', { maximumFractionDigits }).format(value);
8
+ }
9
+
10
+ function pointGrid(count: number): Array<{ x: number; y: number; index: number }> {
11
+ const columns = Math.max(1, Math.ceil(Math.sqrt(count)));
12
+ const rows = Math.max(1, Math.ceil(count / columns));
13
+ const points: Array<{ x: number; y: number; index: number }> = [];
14
+ for (let index = 0; index < count; index += 1) {
15
+ const column = index % columns;
16
+ const row = Math.floor(index / columns);
17
+ points.push({
18
+ x: 14 + (column / Math.max(1, columns - 1)) * 72,
19
+ y: 16 + (row / Math.max(1, rows - 1)) * 68,
20
+ index,
21
+ });
22
+ }
23
+ return points;
24
+ }
25
+
26
+ function renderFieldMap(element: HTMLElement, result: SurveyPlanResult, ui: WildlifeCameraTrapEffortPlannerUI): void {
27
+ const points = pointGrid(Math.min(result.cameraCount, 36));
28
+ const stationMarks = points.map((point) => `
29
+ <g class="wctep-station" transform="translate(${point.x} ${point.y})">
30
+ <rect class="wctep-cell" x="-8" y="-8" width="16" height="16" rx="2" />
31
+ <circle class="wctep-station-dot" r="2.4" />
32
+ <text x="0" y="-11" text-anchor="middle">${point.index + 1}</text>
33
+ </g>`).join('');
34
+ const overflow = result.cameraCount > 36 ? `<text class="wctep-overflow" x="86" y="92" text-anchor="end">+${result.cameraCount - 36}</text>` : '';
35
+ element.innerHTML = `
36
+ <svg viewBox="0 0 100 100" role="img" aria-label="${ui.mapLabel}">
37
+ <title>${ui.mapLabel}</title>
38
+ <desc>${ui.mapCaption}</desc>
39
+ <path class="wctep-contour" d="M6 22 C18 10 29 18 42 10 S70 8 94 20 M4 68 C20 55 30 78 48 63 S76 55 96 72 M16 95 C25 77 46 88 60 79 S82 78 92 88" />
40
+ <path class="wctep-route" d="M9 84 C29 72 32 39 48 47 S68 77 91 28" />
41
+ ${stationMarks}
42
+ ${overflow}
43
+ <path class="wctep-north" d="M91 13 L91 25 M91 13 L88 18 M91 13 L94 18" />
44
+ <text class="wctep-north-label" x="91" y="9" text-anchor="middle">N</text>
45
+ </svg>`;
46
+ }
47
+
48
+ function renderReviewList(root: HTMLElement, result: SurveyPlanResult, ui: WildlifeCameraTrapEffortPlannerUI): void {
49
+ const list = root.querySelector<HTMLOListElement>('[data-review-list]');
50
+ if (!list) return;
51
+ list.replaceChildren(...result.reviewCheckpoints.map((checkpoint) => {
52
+ const item = document.createElement('li');
53
+ const day = document.createElement('strong');
54
+ day.textContent = `${ui.reviewDayLabel} ${number(checkpoint.day)}`;
55
+ const events = document.createElement('span');
56
+ events.textContent = `${number(checkpoint.expectedEvents)} ${ui.reviewEventsLabel}`;
57
+ item.append(day, events);
58
+ return item;
59
+ }));
60
+ }
61
+
62
+ function renderValues(root: HTMLElement, result: SurveyPlanResult, unitSystem: UnitSystem): void {
63
+ const areaPerCamera = unitSystem === 'metric' ? `${number(result.areaPerCameraM2)} m²` : `${number(result.areaPerCameraM2 / M2_PER_ACRE, 2)} ac`;
64
+ const spacing = unitSystem === 'metric' ? `${number(result.evenGridSpacingMeters)} m` : `${number(result.evenGridSpacingMeters / METERS_PER_FOOT)} ft`;
65
+ root.querySelector('[data-camera-days]')!.textContent = number(result.cameraDays);
66
+ root.querySelector('[data-expected-events]')!.textContent = number(result.expectedEvents);
67
+ root.querySelector('[data-area-per-camera]')!.textContent = areaPerCamera;
68
+ root.querySelector('[data-grid-spacing]')!.textContent = spacing;
69
+ root.querySelector('[data-footprint]')!.textContent = `${number(result.nominalFootprintPercent, 1)}%`;
70
+ root.querySelector('[data-review-load]')!.textContent = number(result.expectedEventsPerReview);
71
+ }
72
+
73
+ function renderStatus(root: HTMLElement, result: SurveyPlanResult, ui: WildlifeCameraTrapEffortPlannerUI): void {
74
+ const status = evaluatePlan(result);
75
+ const labels: Record<string, string> = {
76
+ 'needs-input': ui.statusNeedsInput,
77
+ sparse: ui.statusSparse,
78
+ dense: ui.statusDense,
79
+ 'review-heavy': ui.statusReviewHeavy,
80
+ balanced: ui.statusBalanced,
81
+ };
82
+ const statusElement = root.querySelector('[data-status]');
83
+ if (statusElement) statusElement.textContent = labels[status] ?? ui.statusNeedsInput;
84
+ root.dataset.planStatus = status;
85
+ }
86
+
87
+ export function renderPlan(root: HTMLElement, result: SurveyPlanResult, ui: WildlifeCameraTrapEffortPlannerUI, unitSystem: UnitSystem): void {
88
+ renderFieldMap(root.querySelector<HTMLElement>('[data-field-map]')!, result, ui);
89
+ renderValues(root, result, unitSystem);
90
+ renderStatus(root, result, ui);
91
+ renderReviewList(root, result, ui);
92
+ }
93
+
94
+ export function formatInputValue(value: number, unitSystem: UnitSystem, kind: 'area' | 'spacing'): string {
95
+ if (kind === 'area' && unitSystem === 'imperial') return String(Math.round((value * ACRES_PER_HECTARE) * 100) / 100);
96
+ if (kind === 'spacing' && unitSystem === 'imperial') return String(Math.round((value / METERS_PER_FOOT) * 10) / 10);
97
+ return String(value);
98
+ }
@@ -0,0 +1,29 @@
1
+ import type { NatureToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { WildlifeCameraTrapEffortPlannerUI } from './ui';
3
+
4
+ export type WildlifeCameraTrapEffortPlannerLocaleContent = ToolLocaleContent<WildlifeCameraTrapEffortPlannerUI>;
5
+
6
+ export const wildlifeCameraTrapEffortPlanner: NatureToolEntry<WildlifeCameraTrapEffortPlannerUI> = {
7
+ id: 'wildlife-camera-trap-effort-planner',
8
+ icons: {
9
+ bg: 'mdi:forest-outline',
10
+ fg: 'mdi:camera-outline',
11
+ },
12
+ i18n: {
13
+ en: async () => (await import('./i18n/en')).content,
14
+ de: async () => (await import('./i18n/de')).content,
15
+ es: async () => (await import('./i18n/es')).content,
16
+ fr: async () => (await import('./i18n/fr')).content,
17
+ id: async () => (await import('./i18n/id')).content,
18
+ it: async () => (await import('./i18n/it')).content,
19
+ ja: async () => (await import('./i18n/ja')).content,
20
+ ko: async () => (await import('./i18n/ko')).content,
21
+ nl: async () => (await import('./i18n/nl')).content,
22
+ pl: async () => (await import('./i18n/pl')).content,
23
+ pt: async () => (await import('./i18n/pt')).content,
24
+ ru: async () => (await import('./i18n/ru')).content,
25
+ sv: async () => (await import('./i18n/sv')).content,
26
+ tr: async () => (await import('./i18n/tr')).content,
27
+ zh: async () => (await import('./i18n/zh')).content,
28
+ },
29
+ };
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { evaluatePlan } from './evaluator';
3
+ import { calculateSurveyPlan, DEFAULT_INPUTS } from './logic';
4
+
5
+ describe('Wildlife camera trap plan evaluator', () => {
6
+ it('recognizes a balanced plan', () => {
7
+ expect(evaluatePlan(calculateSurveyPlan(DEFAULT_INPUTS))).toBe('balanced');
8
+ });
9
+
10
+ it('flags target spacing that is sparse or dense for the area', () => {
11
+ expect(evaluatePlan(calculateSurveyPlan({ ...DEFAULT_INPUTS, areaHectares: 100, cameraCount: 1, spacingMeters: 100 }))).toBe('sparse');
12
+ expect(evaluatePlan(calculateSurveyPlan({ ...DEFAULT_INPUTS, areaHectares: 1, cameraCount: 100, spacingMeters: 100, triggerRatePerCameraDay: 0.1 }))).toBe('dense');
13
+ });
14
+
15
+ it('flags a review load that needs attention', () => {
16
+ expect(evaluatePlan(calculateSurveyPlan({ ...DEFAULT_INPUTS, triggerRatePerCameraDay: 10 }))).toBe('review-heavy');
17
+ });
18
+
19
+ it('requires a complete deployment before interpreting it', () => {
20
+ expect(evaluatePlan(calculateSurveyPlan({ ...DEFAULT_INPUTS, deploymentDays: 0 }))).toBe('needs-input');
21
+ });
22
+ });
@@ -0,0 +1,11 @@
1
+ import type { SurveyPlanResult } from './logic';
2
+
3
+ export type PlanStatus = 'needs-input' | 'sparse' | 'dense' | 'review-heavy' | 'balanced';
4
+
5
+ export function evaluatePlan(result: SurveyPlanResult): PlanStatus {
6
+ if (result.areaM2 === 0 || result.cameraCount === 0 || result.deploymentDays === 0) return 'needs-input';
7
+ if (result.expectedEventsPerReview >= 500) return 'review-heavy';
8
+ if (result.evenGridSpacingMeters > result.spacingMeters * 1.25) return 'sparse';
9
+ if (result.evenGridSpacingMeters < result.spacingMeters * 0.75) return 'dense';
10
+ return 'balanced';
11
+ }
@@ -0,0 +1,49 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
2
+ import { bibliography } from '../bibliography';
3
+ import type { WildlifeCameraTrapEffortPlannerLocaleContent } from '../entry';
4
+
5
+ const slug = 'wildkamera-einsatzplan-wildtiermonitoring';
6
+ const title = 'Planer für den Einsatz von Wildkameras';
7
+ const description = 'Plane Kameraaufwand, schätze Kameratage und Bildprüfung und skizziere ein Einsatzraster ohne Aussagen zur Populationsgröße.';
8
+ const faqData = [
9
+ { question: 'Was sind Kameratage?', answer: 'Kameratage sind die Anzahl der Kameras multipliziert mit der Zahl ihrer aktiven Tage. Sie beschreiben den Stichprobenaufwand, nicht die Zahl der Tiere in einem Gebiet.' },
10
+ { question: 'Berechnet der Planer die Größe einer Wildtierpopulation?', answer: 'Nein. Er schätzt den Arbeitsaufwand und eine nominale Planungsfläche. Populationsgröße, Abundanz und Belegung erfordern ein validiertes Studiendesign und ein Modell für unvollständige Entdeckung.' },
11
+ { question: 'Was bewirkt der Abstand?', answer: 'Der Abstand erzeugt eine nominale quadratische Planungszelle pro Kamera und vergleicht sie mit dem gleichmäßigen Rasterabstand aus Fläche und Kamerazahl. Er ist kein Erfassungsradius.' },
12
+ { question: 'Wie nutze ich den Prüfplan?', answer: 'Nutze die Wochenpunkte als Arbeitsrhythmus. Vergleiche die erwartete Ereignismenge mit Speicher, Batterien und Prüfkapazität und protokolliere jeden echten Besuch.' },
13
+ ];
14
+ const howToData = [
15
+ { name: 'Untersuchungsfläche beschreiben', text: 'Gib die Fläche ein, die der Einsatz untersuchen soll. Der Schalter Metric oder Imperial bewahrt den physikalischen Wert beim Wechsel.' },
16
+ { name: 'Einsatzraster planen', text: 'Gib Kamerazahl, Einsatzdauer und Zielabstand ein. Die Skizze zeigt Stationen als Planungsraster, nicht als Koordinaten oder garantierte Erfassungszonen.' },
17
+ { name: 'Bildaufwand schätzen', text: 'Gib erwartete Auslösungen pro Kamera und Tag ein. Der Planer multipliziert sie mit den Kameratagen und verteilt sie auf Wochenpunkte.' },
18
+ { name: 'Feldprotokoll vorbereiten', text: 'Vergib eindeutige Kamera- und Sitzungscodes, notiere Aufstellung und erfasse jede Kontrolle, auch wenn kein Tier erkannt wurde.' },
19
+ ];
20
+ const faqSchema: WithContext<FAQPage> = { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faqData.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
21
+ const howToSchema: WithContext<HowTo> = { '@context': 'https://schema.org', '@type': 'HowTo', name: title, description, step: howToData.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })) };
22
+ const appSchema: WithContext<SoftwareApplication> = { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: title, description, applicationCategory: 'UtilityApplication', operatingSystem: 'All', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' }, inLanguage: 'de' };
23
+
24
+ export const content: WildlifeCameraTrapEffortPlannerLocaleContent = {
25
+ slug, title, description,
26
+ ui: {
27
+ areaPerCameraLabel: 'Fläche pro Kamera',
28
+ labelSurveyArea: 'Untersuchungsfläche', labelCameraCount: 'Kamerastationen', labelDeploymentDays: 'Einsatztage', labelSpacing: 'Zielabstand der Stationen', labelTriggerRate: 'Erwartete Auslösungen pro Kamera und Tag',
29
+ unitHectares: 'ha', unitAcres: 'ac', unitMeters: 'm', unitFeet: 'ft', unitCameras: 'Kameras', unitDays: 'Tage', unitEventsPerDay: 'Ereignisse pro Tag',
30
+ helpSurveyArea: 'Die Fläche, die der Einsatz untersuchen soll.', helpCameraCount: 'Aktive Kameras dieser Kampagne.', helpDeploymentDays: 'Tage zwischen Aufbau und Abbau.', helpSpacing: 'Eine Planungshilfe, kein Erfassungsradius.', helpTriggerRate: 'Nutze einen vorsichtigen Wert aus einem vergleichbaren Einsatz oder Test.',
31
+ unitSystemLabel: 'Messsystem', metricLabel: 'Metrisch', imperialLabel: 'Imperial', presetsTitle: 'Startpunkte fuer das Feld', presetBackyard: 'Waldrand am Haus', presetWoodland: 'Mischwald', presetReserve: 'Grosses Schutzgebiet',
32
+ mapLabel: 'Relative Skizze des Kameraeinsatzes', mapCaption: 'Nummerierte Stationen in einem relativen Planungsraster. Die Zellen helfen beim Abstand, sind aber keine Habitatkarte.', stationLabel: 'Station', cameraDaysLabel: 'Kameratage', expectedEventsLabel: 'Erwartete Ereignisse', spacingLabel: 'Abstand im gleichmäßigen Raster', reviewLoadLabel: 'Ereignisse pro Kontrolle', reviewScheduleTitle: 'Kontrollrhythmus', reviewScheduleText: 'Wochenpunkte machen den Bildaufwand sichtbar, bevor die Kameras ausgebracht werden. Ersetze sie durch die Besuche deines Feldprotokolls.', reviewDayLabel: 'Tag', reviewEventsLabel: 'Ereignisse erwartet',
33
+ statusBalanced: 'Abstand wirkt ausgewogen', statusSparse: 'Stationen wirken weit verteilt', statusDense: 'Stationen wirken eng verteilt', statusReviewHeavy: 'Prüfaufwand braucht Aufmerksamkeit', statusNeedsInput: 'Vollständigen Einsatzplan eingeben', statusNote: 'Dies ist ein Aufwandsplan. Er sagt nicht, wie viele Tiere im Gebiet leben, und garantiert keine Artentdeckung.',
34
+ assumptionTitle: 'Annahmen des Modells', assumptionText: 'Die nominale Fläche behandelt jede Station als quadratische Zelle aus dem Zielabstand. Die Ereignisse setzen voraus, dass die Auslöserate bei allen Kameras und an allen Tagen gleich bleibt.', limitationTitle: 'Grenzen im Feld', limitationText: 'Wähle Standorte nach Art und Fragestellung, nicht nur nach Geometrie. Notiere Kamera-IDs, Daten, Ausrichtung, Höhe, Habitat und Leerprüfungen, damit unvollständige Entdeckung später berücksichtigt werden.',
35
+ },
36
+ seo: [
37
+ { type: 'title', text: 'Aufwand fuer Fotofallen planen', level: 2 },
38
+ { type: 'paragraph', html: 'Ein Fotofallenprojekt hat vor der Artenliste zwei Planungsfragen. Wie viel Stichprobenaufwand stellt der Einsatz dar? Und kann das erwartete Bildaufkommen geprueft, gespeichert und dokumentiert werden? Dieser Planer macht daraus Kameratage, erwartete Ereignisse, eine relative Stationsskizze und Kontrollpunkte.' },
39
+ { type: 'title', text: 'Kameratage als Aufwand nutzen', level: 3 },
40
+ { type: 'paragraph', html: 'Kameratage berechnen sich als <code>Kamerastationen × Einsatztage</code>. Zwölf Kameras für 28 Tage ergeben 336 Kameratage. Das hilft beim Vergleich zweier Kampagnen, ist aber keine Populationsschätzung und korrigiert weder Ausfälle noch Habitatunterschiede oder Tierverhalten.' },
41
+ { type: 'list', items: ['<strong>Kamerastationen:</strong> die aktiven Geraete der Kampagne.', '<strong>Einsatztage:</strong> die geplanten Tage zwischen Aufbau und Abbau.', '<strong>Ausloeserate:</strong> Ereignisse pro Kamera und Tag, nicht jede einzelne Aufnahme einer Serie.', '<strong>Kontrollpunkte:</strong> Aufwand fuer Speicher, Batterien, Besuche und Bildpruefung planen.'] },
42
+ { type: 'title', text: 'Die Feldskizze als Planungshilfe lesen', level: 3 },
43
+ { type: 'paragraph', html: 'Die Skizze nutzt Flaeche, Kamerazahl und Zielabstand fuer eine relative Anordnung. Die nominale Flaeche ist Stationszahl mal Zielabstand im Quadrat. Das ist eine transparente Geometriehilfe, aber keine Sensorflaeche. Gelaende, Vegetation, Wege, Hoehe, Ausrichtung und Verhalten veraendern die Entdeckungswahrscheinlichkeit.' },
44
+ { type: 'title', text: 'Vor dem Einsatz bessere Aufzeichnungen vorbereiten', level: 3 },
45
+ { type: 'paragraph', html: 'Definiere vor dem Aufbau Zielarten und Fragestellung. Vergib stabile Kennungen fuer Kamera und Einsatz. Erfasse bei Aufbau und Kontrolle Datum, Ort, Hoehe, Ausrichtung, Habitat, Einstellungen und Funktionsstatus. Auch eine Kontrolle ohne Fund ist ein echter Eintrag.' },
46
+ { type: 'tip', title: 'Aufwand nicht mit Abundanz verwechseln', html: 'Fotofallen haben eine unvollstaendige Entdeckung. Nutze den Planer fuer Feldaufwand und Bildbearbeitung und ein validiertes oekologisches Design fuer Belegung, Abundanz, Dichte oder Populationsveraenderung.' },
47
+ ],
48
+ faq: faqData, howTo: howToData, bibliography, schemas: [faqSchema, howToSchema, appSchema],
49
+ };