Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add function to generate some of the metrics report #261

Merged
merged 9 commits into from
Apr 23, 2024
74 changes: 72 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@formatjs/intl-relativetimeformat": "^11.2.4",
"@gorhom/bottom-sheet": "^4.5.1",
"@mapeo/ipc": "0.3.0",
"@osm_borders/maritime_10000m": "^1.1.0",
"@react-native-community/hooks": "^2.8.0",
"@react-native-community/netinfo": "11.1.0",
"@react-native-picker/picker": "2.6.1",
Expand All @@ -50,6 +51,7 @@
"expo-location": "~16.5.4",
"expo-secure-store": "~12.8.1",
"expo-sensors": "~12.9.1",
"geojson-geometries-lookup": "^0.5.0",
"lodash.isequal": "^4.5.0",
"nanoid": "^5.0.1",
"nodejs-mobile-react-native": "^18.17.7",
Expand Down Expand Up @@ -98,6 +100,7 @@
"@react-native/typescript-config": "^0.74.0",
"@testing-library/react-native": "^12.4.3",
"@types/debug": "^4.1.7",
"@types/geojson": "^7946.0.14",
"@types/jest": "^29.5.12",
"@types/lodash.isequal": "^4.5.6",
"@types/node": "^20.8.4",
Expand Down
105 changes: 105 additions & 0 deletions src/frontend/metrics/generateMetricsReport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as path from 'node:path';
import * as fs from 'node:fs';
import generateMetricsReport from './generateMetricsReport';

describe('generateMetricsReport', () => {
const packageJson = readPackageJson();

const defaultOptions: Parameters<typeof generateMetricsReport>[0] = {
packageJson,
os: 'android',
osVersion: 123,
screen: {width: 12, height: 34},
observations: [
// Middle of the Atlantic
{lat: 10, lon: -33},
// Mexico City
{lat: 19.419914, lon: -99.088059},
// Machias Seal Island, disputed territory
{lat: 44.5, lon: -67.101111},
// To be ignored
{},
{lat: 12},
{lon: 34},
],
};

it('can be serialized and deserialized as JSON', () => {
const report = generateMetricsReport(defaultOptions);
const actual = JSON.parse(JSON.stringify(report));
const expected = removeUndefinedEntries(report);
expect(actual).toEqual(expected);
});

it('includes a report type', () => {
const report = generateMetricsReport(defaultOptions);
expect(report.type).toBe('metrics-v1');
});

it('includes the app version', () => {
const report = generateMetricsReport(defaultOptions);
expect(report.appVersion).toBe(packageJson.version);
});

it('includes the OS (Android style)', () => {
const report = generateMetricsReport(defaultOptions);
expect(report.os).toBe('android');
expect(report.osVersion).toBe(123);
});

it('includes the OS (iOS style)', () => {
const options = {...defaultOptions, os: 'ios' as const, osVersion: '1.2.3'};
const report = generateMetricsReport(options);
expect(report.os).toBe('ios');
expect(report.osVersion).toBe('1.2.3');
});

it('includes the OS (desktop style)', () => {
const options = {
...defaultOptions,
os: 'win32' as const,
osVersion: '1.2.3',
};
const report = generateMetricsReport(options);
expect(report.os).toBe('win32');
expect(report.osVersion).toBe('1.2.3');
});

it('includes screen dimensions', () => {
const report = generateMetricsReport(defaultOptions);
expect(report.screen).toEqual({width: 12, height: 34});
});

it("doesn't include countries if no observations are provided", () => {
const options = {...defaultOptions, observations: []};
const report = generateMetricsReport(options);
expect(report.countries).toBe(undefined);
});

it('includes countries where observations are found', () => {
const report = generateMetricsReport(defaultOptions);
expect(report.countries).toHaveLength(new Set(report.countries).size);
expect(new Set(report.countries)).toEqual(new Set(['MEX', 'CAN', 'USA']));
});
});

function readPackageJson() {
const packageJsonPath = path.resolve(
__dirname,
'..',
'..',
'..',
'package.json',
);
const packageJsonData = fs.readFileSync(packageJsonPath, 'utf8');
return JSON.parse(packageJsonData);
}

function removeUndefinedEntries(
obj: Record<string, unknown>,
): Record<string, unknown> {
const definedEntries = Object.entries(obj).filter(
entry => entry[1] !== undefined,
);
return Object.fromEntries(definedEntries);
}
38 changes: 38 additions & 0 deletions src/frontend/metrics/generateMetricsReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type {ReadonlyDeep} from 'type-fest';
import type {Observation} from '@mapeo/schema';
import positionToCountries from './positionToCountries';

export default function generateMetricsReport({
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function takes a bunch of dependencies. It's more verbose, but I think it has some advantages:

  • Easier to port to Electron in the future.
  • Clearer to the caller what goes in; data isn't pulled from anywhere unexpected.
  • Easier to test.

packageJson,
os,
osVersion,
screen,
observations,
}: ReadonlyDeep<{
packageJson: {version: string};
os: 'android' | 'ios' | NodeJS.Platform;
osVersion: number | string;
screen: {width: number; height: number};
observations: ReadonlyArray<Pick<Observation, 'lat' | 'lon'>>;
}>) {
const countries = new Set<string>();

for (const {lat, lon} of observations) {
if (typeof lat === 'number' && typeof lon === 'number') {
addToSet(countries, positionToCountries(lat, lon));
}
}

return {
type: 'metrics-v1',
appVersion: packageJson.version,
os,
osVersion,
screen,
...(countries.size ? {countries: Array.from(countries)} : {}),
};
}

function addToSet<T>(set: Set<T>, toAdd: Iterable<T>): void {
for (const item of toAdd) set.add(item);
}
27 changes: 27 additions & 0 deletions src/frontend/metrics/positionToCountries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import positionToCountries from './positionToCountries';

describe('positionToCountries', () => {
it('returns nothing for invalid values', () => {
expect(positionToCountries(-91, 181)).toEqual(new Set());
expect(positionToCountries(Infinity, -Infinity)).toEqual(new Set());
expect(positionToCountries(NaN, NaN)).toEqual(new Set());
});

it('returns nothing for the middle of the Atlantic ocean', () => {
expect(positionToCountries(10, -33)).toEqual(new Set());
});

it('returns Mexico for a point in Mexico City', () => {
expect(positionToCountries(19.419914, -99.088059)).toEqual(
new Set(['MEX']),
);
});

it('returns multiple countries for disputed territories', () => {
// [Machias Seal Island][0] is a disputed territory.
// [0]: https://en.wikipedia.org/wiki/Machias_Seal_Island
expect(positionToCountries(44.5, -67.101111)).toEqual(
new Set(['CAN', 'USA']),
);
});
});
29 changes: 29 additions & 0 deletions src/frontend/metrics/positionToCountries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import borders from '@osm_borders/maritime_10000m';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This data is accurate within 10 kilometers. There are other choices with higher resolution but (1) this is small and therefore the best for performance1 (2) high accuracy might violate user privacy.

Footnotes

  1. I assume

import GeojsonGeometriesLookup from 'geojson-geometries-lookup';

let lookup: undefined | GeojsonGeometriesLookup;

export default function positionToCountries(
latitude: number,
longitude: number,
): Set<string> {
lookup ??= new GeojsonGeometriesLookup(borders);

const result = new Set<string>();

const {features} = lookup.getContainers({
type: 'Point',
coordinates: [longitude, latitude],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought this was backwards, but I believe it's right (and this function is unit tested).

});
for (const {properties} of features) {
if (
properties &&
'isoA3' in properties &&
typeof properties.isoA3 === 'string'
) {
result.add(properties.isoA3);
}
}

return result;
}
15 changes: 15 additions & 0 deletions src/frontend/types/geojson-geometries-lookup.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
declare module 'geojson-geometries-lookup' {
import type {
GeoJSON,
Point,
LineString,
Polygon,
FeatureCollection,
} from '@types/geojson';

export default class GeojsonGeometriesLookup {
constructor(geoJson: GeoJSON);

getContainers(geometry: Point | LineString | Polygon): FeatureCollection;
}
}
6 changes: 6 additions & 0 deletions src/frontend/types/osm_borders__maritime_10000m.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
declare module '@osm_borders/maritime_10000m' {
import {GeoJSON} from 'geojson';

const data: GeoJSON;
export default data;
}
Loading