diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c7c71..8b8e4f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this package are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/). +## Unreleased + +### Added + +- `examples/`: a tap-to-measure React Native screen, a detector-to-millimetres + helper, and a two-photo comparison that declines different distances. CI + typechecks and runs them. Not part of the published package. + ## 1.0.0 First public release. Breaking changes from 0.x, which was never published. diff --git a/README.md b/README.md index f85ff12..74afd39 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,18 @@ Scale comes from the ellipse's **major axis**. A round object photographed at an angle looks oval, but its long axis still spans the true diameter; the short axis does not. +### Complete examples + +[`examples/`](examples/) has copy-paste starting points, typechecked and run in +CI so they stay in step with the API: + +- [`TapToMeasure.tsx`](examples/TapToMeasure.tsx): a React Native screen that + measures with four taps, no detector needed. +- [`fromDetector.ts`](examples/fromDetector.ts): your detector's ellipse to + lengths and areas, with a stricter tilt limit. +- [`compareTwoPhotos.ts`](examples/compareTwoPhotos.ts): check two photos were + taken at a similar distance before showing both sizes. + ## API | Function | Returns | diff --git a/__tests__/examples.test.ts b/__tests__/examples.test.ts new file mode 100644 index 0000000..c47d579 --- /dev/null +++ b/__tests__/examples.test.ts @@ -0,0 +1,93 @@ +// The examples in examples/ are documentation; this runs the plain ones so a +// change that breaks their numbers fails here, not in someone's app. +import { + compare, + describe as describeResult, +} from '../examples/compareTwoPhotos'; +import { example, measure } from '../examples/fromDetector'; + +describe('examples/fromDetector', () => { + it('turns a 200 px sticker into millimetres', () => { + expect(example).toMatchObject({ + ok: true, + lengthMm: 6, + areaMm2: 27.5, + roughScale: false, + }); + // 200 x 190 px is a slight tilt, well inside the 20° limit. + expect(example.ok && example.tiltDegrees).toBeGreaterThan(0); + expect(example.ok && example.tiltDegrees).toBeLessThan(20); + }); + + it('explains a refusal instead of measuring', () => { + const result = measure({ + reference: { majorAxisPx: 200, minorAxisPx: 100 }, // 60° tilt + referenceKind: 'STICKER_10MM', + subjectLengthPx: 120, + subjectAreaPx: 11000, + }); + expect(result.ok).toBe(false); + }); + + it('flags polygon coins as a rough scale', () => { + const result = measure({ + reference: { majorAxisPx: 400, minorAxisPx: 395 }, + referenceKind: 'GBP_1', + subjectLengthPx: 100, + subjectAreaPx: 5000, + }); + expect(result).toMatchObject({ ok: true, roughScale: true }); + }); + + it('reports a missing size', () => { + const result = measure({ + reference: { majorAxisPx: 200, minorAxisPx: 200 }, + referenceKind: 'STICKER_10MM', + subjectLengthPx: 0, + subjectAreaPx: 0, + }); + expect(result).toEqual({ + ok: false, + message: 'The detector found no size to measure.', + }); + }); +}); + +describe('examples/compareTwoPhotos', () => { + const photo = (majorAxisPx: number, subjectLengthPx: number) => ({ + takenAt: '2026-03-01', + reference: { majorAxisPx, minorAxisPx: majorAxisPx }, + subjectLengthPx, + }); + + it('shows both estimates when the distances are similar', () => { + const result = compare(photo(200, 120), photo(210, 130)); + expect(result).toEqual({ kind: 'comparable', earlierMm: 6, laterMm: 6.2 }); + expect(describeResult(result)).toBe('Estimates: 6.0 mm then 6.2 mm.'); + }); + + it('declines to compare photos taken at very different distances', () => { + const result = compare(photo(100, 60), photo(300, 180)); + expect(result).toEqual({ kind: 'different_distance' }); + expect(describeResult(result)).toMatch(/different distances/); + }); + + it('says when a photo cannot be measured', () => { + const result = compare(photo(10, 60), photo(200, 120)); + expect(result).toEqual({ kind: 'not_measurable' }); + expect(describeResult(result)).toMatch(/cannot be measured/); + }); + + it('never judges a change', () => { + const texts = [ + describeResult({ kind: 'comparable', earlierMm: 4, laterMm: 9 }), + describeResult({ kind: 'different_distance' }), + describeResult({ kind: 'not_measurable' }), + ]; + texts.forEach((text) => { + expect(text).not.toMatch( + /grow|grown|bigger|worse|concern|risk|see a doctor/i + ); + }); + }); +}); diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..337058e --- /dev/null +++ b/examples/README.md @@ -0,0 +1,13 @@ +# Examples + +Complete, copy-paste starting points. CI typechecks every file here against +the package, so they stay in step with the API. + +| File | Shows | +| -------------------------------------------- | ---------------------------------------------------------------------------------- | +| [`TapToMeasure.tsx`](TapToMeasure.tsx) | A React Native screen that measures with four taps, no detector needed | +| [`fromDetector.ts`](fromDetector.ts) | Turning your detector's ellipse into lengths and areas, with a stricter tilt limit | +| [`compareTwoPhotos.ts`](compareTwoPhotos.ts) | Checking two photos were taken at a similar distance before showing both sizes | + +Every result is an estimate and is shown as one. The examples report numbers +only and never say whether a size or a change matters. diff --git a/examples/TapToMeasure.tsx b/examples/TapToMeasure.tsx new file mode 100644 index 0000000..a4bf0c8 --- /dev/null +++ b/examples/TapToMeasure.tsx @@ -0,0 +1,95 @@ +/** + * Measure with taps, no detector needed. + * + * The person taps the two edges of the sticker (or coin) in the photo, then + * the two ends of what they want to measure. The distance between the first + * two taps is the reference's diameter in pixels; the second two give the + * length to convert. Always shown as an estimate. + */ +import { useState } from 'react'; +import { + Button, + Image, + Pressable, + Text, + View, + type GestureResponderEvent, +} from 'react-native'; +import { + REFERENCE_OBJECTS, + explainRefusal, + scaleFromEllipse, + toMillimetres, +} from '@molecare/scale-reference'; + +interface Point { + x: number; + y: number; +} + +const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y); + +const PROMPTS = [ + 'Tap one edge of the sticker', + 'Tap the opposite edge of the sticker', + 'Tap one end of what you want to measure', + 'Tap the other end', +]; + +export function TapToMeasure({ photoUri }: { photoUri: string }) { + const [taps, setTaps] = useState([]); + + const onTap = (event: GestureResponderEvent) => { + if (taps.length >= 4) return; + const { locationX, locationY } = event.nativeEvent; + setTaps([...taps, { x: locationX, y: locationY }]); + }; + + let result: string | null = null; + const [a, b, c, d] = taps; + if (a && b && c && d) { + // Taps on a round sticker give its diameter; a tapped width has no tilt + // information, so the major and minor axes are the same. + const referencePx = distance(a, b); + const scale = scaleFromEllipse( + { majorAxisPx: referencePx, minorAxisPx: referencePx }, + REFERENCE_OBJECTS.STICKER_10MM.diameterMm + ); + if (!scale.usable) { + // Your own words, or translations, keyed by reason. + result = explainRefusal(scale.reason, { + reference_too_small: + 'The sticker is too small in this photo. Move the camera closer.', + default: 'This photo cannot be measured. Try another one.', + }); + } else { + const mm = toMillimetres(distance(c, d), scale); + result = + mm === null + ? 'Tap two different points.' + : `About ${mm.toFixed(1)} mm (an estimate)`; + } + } + + return ( + + + + + {result ?? PROMPTS[taps.length]} +