From 2dd8ccac24c151e6e999d83a965ed68932b67429 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 17:15:14 +0200 Subject: [PATCH 1/7] fix(organisms): allow linking existing organisms in report changes Relax unique-name validation in the request-changes organism repeater so users can link organisms that already exist in the database. Reject names already attached to the target molecule instead. Fixes #772 --- app/Models/Organism.php | 47 +++++++++++++-- tests/Unit/OrganismReportChangeFormTest.php | 65 +++++++++++++++++++++ 2 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/OrganismReportChangeFormTest.php diff --git a/app/Models/Organism.php b/app/Models/Organism.php index 85ac84a7..64ff4ac6 100644 --- a/app/Models/Organism.php +++ b/app/Models/Organism.php @@ -2,6 +2,7 @@ namespace App\Models; +use Closure; use Filament\Forms; use Filament\Forms\Components\Actions\Action; use Illuminate\Contracts\View\View; @@ -79,13 +80,49 @@ public function transformAudit(array $data): array return changeAudit($data); } - public static function getForm(): array + public static function validateNameForReportChange(string $name, array $linkedNames): ?string { + if (in_array($name, $linkedNames, true)) { + return 'This organism is already linked to this molecule.'; + } + + return null; + } + + /** + * @return array + */ + public static function reportChangeNameRules(array $linkedNames = []): array + { + if ($linkedNames === []) { + return []; + } + + return [ + function (string $attribute, mixed $value, Closure $fail) use ($linkedNames): void { + $message = static::validateNameForReportChange((string) $value, $linkedNames); + + if ($message !== null) { + $fail($message); + } + }, + ]; + } + + public static function getForm(bool $requireUniqueName = true, array $excludedNames = []): array + { + $nameField = Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255); + + if ($requireUniqueName) { + $nameField->unique(Organism::class, 'name'); + } else { + $nameField->rules(static::reportChangeNameRules($excludedNames)); + } + return [ - Forms\Components\TextInput::make('name') - ->required() - ->unique(Organism::class, 'name') - ->maxLength(255) + $nameField // ->suffixAction( // Action::make('infoFromSources') // ->icon('heroicon-m-clipboard') diff --git a/tests/Unit/OrganismReportChangeFormTest.php b/tests/Unit/OrganismReportChangeFormTest.php new file mode 100644 index 00000000..9278ebf6 --- /dev/null +++ b/tests/Unit/OrganismReportChangeFormTest.php @@ -0,0 +1,65 @@ + 'Escherichia coli', 'slug' => 'escherichia-coli']); + + $this->assertNull( + Organism::validateNameForReportChange('Escherichia coli', ['Homo sapiens']) + ); + } + + public function test_validate_name_for_report_change_rejects_already_linked_organism_name(): void + { + $message = Organism::validateNameForReportChange('Homo sapiens', ['Homo sapiens']); + + $this->assertSame('This organism is already linked to this molecule.', $message); + } + + public function test_report_change_name_rules_allow_existing_database_organism_name(): void + { + Organism::create(['name' => 'Escherichia coli', 'slug' => 'escherichia-coli']); + + $rules = ['name' => Organism::reportChangeNameRules(['Homo sapiens'])]; + + $validator = Validator::make(['name' => 'Escherichia coli'], $rules); + + $this->assertFalse($validator->fails()); + } + + public function test_report_change_name_rules_reject_already_linked_organism_name(): void + { + $rules = ['name' => Organism::reportChangeNameRules(['Homo sapiens'])]; + + $validator = Validator::make(['name' => 'Homo sapiens'], $rules); + + $this->assertTrue($validator->fails()); + $this->assertSame( + 'This organism is already linked to this molecule.', + $validator->errors()->first('name') + ); + } + + public function test_default_get_form_still_requires_unique_organism_name(): void + { + Organism::create(['name' => 'Escherichia coli', 'slug' => 'escherichia-coli']); + + $validator = Validator::make( + ['name' => 'Escherichia coli'], + ['name' => ['required', 'unique:organisms,name']] + ); + + $this->assertTrue($validator->fails()); + } +} From 0b288fef33d9cd69711ff55a88fb2b99a607d784 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 17:15:17 +0200 Subject: [PATCH 2/7] fix(reports): correct select validation in request changes form Align Filament v4 Select option keys with stored state for existing synonyms, CAS, geo locations, organisms, and citations. Resolve the target molecule from form state and report records so options remain valid on Livewire submit requests. Fixes #775 --- .../Dashboard/Resources/ReportResource.php | 156 +++++++++++++----- .../ReportResourceMoleculeOptionsTest.php | 89 ++++++++++ 2 files changed, 200 insertions(+), 45 deletions(-) create mode 100644 tests/Unit/ReportResourceMoleculeOptionsTest.php diff --git a/app/Filament/Dashboard/Resources/ReportResource.php b/app/Filament/Dashboard/Resources/ReportResource.php index cfb13560..21e120d1 100644 --- a/app/Filament/Dashboard/Resources/ReportResource.php +++ b/app/Filament/Dashboard/Resources/ReportResource.php @@ -29,6 +29,7 @@ use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Forms\Components\Checkbox; +use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Radio; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; @@ -65,21 +66,16 @@ class ReportResource extends Resource protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack'; - protected static $molecule = null; - protected static $approved_changes = null; protected static $overall_changes = null; - public function __construct() - { - self::$molecule = request()->has('compound_id') ? Molecule::where('identifier', request()->compound_id)->first() : null; - } - public static function form(Schema $schema): Schema { return $schema ->components([ + Hidden::make('compound_id'), + Hidden::make('type'), Grid::make() ->schema([ Select::make('report_category') @@ -509,13 +505,11 @@ public static function form(Schema $schema): Schema Select::make('existing_geo_locations') ->label('Existing') ->multiple() - ->options(function (): array { - $geo_locations = []; - if (self::$molecule) { - $geo_locations = self::$molecule->geo_locations->pluck('name', 'id')->toArray(); - } + ->options(function (Get $get, ?Report $record): array { + $molecule = static::resolveMolecule($record, $get); + $names = $molecule?->geo_locations->pluck('name')->toArray() ?? []; - return $geo_locations; + return static::valueKeyedOptions(array_merge($names, $get('existing_geo_locations') ?? [])); }) ->disabled(function (Get $get, string $operation) { return ! $get('show_geo_location_existing') && $operation == 'edit'; @@ -546,13 +540,11 @@ public static function form(Schema $schema): Schema Select::make('existing_synonyms') ->label('Existing') ->multiple() - ->options(function (): array { - $synonyms = []; - if (self::$molecule) { - $synonyms = self::$molecule->synonyms; - } + ->options(function (Get $get, ?Report $record): array { + $molecule = static::resolveMolecule($record, $get); + $synonyms = array_merge($molecule?->synonyms ?? [], $get('existing_synonyms') ?? []); - return empty($synonyms) ? [] : $synonyms; + return static::valueKeyedOptions($synonyms); }) ->disabled(function (Get $get, string $operation) { return ! $get('show_synonym_existing') && $operation == 'edit'; @@ -581,10 +573,8 @@ public static function form(Schema $schema): Schema }) ->columnSpan(1), Textarea::make('name') - ->default(function () { - if (self::$molecule) { - return self::$molecule->name; - } + ->default(function (Get $get, ?Report $record) { + return static::resolveMolecule($record, $get)?->name; }) ->disabled(function (Get $get, string $operation) { return ! $get('show_name_change') && $operation == 'edit'; @@ -605,10 +595,14 @@ public static function form(Schema $schema): Schema Select::make('existing_cas') ->label('Existing') ->multiple() - ->options(function () { - if (self::$molecule) { - return self::$molecule->cas; - } + ->options(function (Get $get, ?Report $record): array { + $molecule = static::resolveMolecule($record, $get); + $cas = array_merge( + array_values($molecule?->cas ?? []), + $get('existing_cas') ?? [], + ); + + return static::valueKeyedOptions($cas); }) ->disabled(function (Get $get, string $operation) { return ! $get('show_cas_existing') && $operation == 'edit'; @@ -642,10 +636,11 @@ public static function form(Schema $schema): Schema Select::make('existing_organisms') ->label('Existing') ->multiple() - ->options(function () { - if (self::$molecule) { - return self::$molecule->organisms->pluck('name', 'id')->toArray(); - } + ->options(function (Get $get, ?Report $record): array { + $molecule = static::resolveMolecule($record, $get); + $names = $molecule?->organisms->pluck('name')->toArray() ?? []; + + return static::valueKeyedOptions(array_merge($names, $get('existing_organisms') ?? [])); }) ->disabled(function (Get $get, string $operation) { return ! $get('show_organism_existing') && $operation == 'edit'; @@ -657,16 +652,24 @@ public static function form(Schema $schema): Schema Repeater::make('new_organisms') ->label('') - ->schema([ - Checkbox::make('approve_new_organism') - ->label('Approve') - ->hidden(function (string $operation) { - return ! auth()->user()->isCurator() || $operation == 'create'; - }) - ->columnSpanFull(), - Grid::make() - ->schema(Organism::getForm()), - ]) + ->schema(function (Get $get, ?Report $record): array { + $molecule = static::resolveMolecule($record, $get); + $linkedNames = $molecule?->organisms->pluck('name')->toArray() ?? []; + + return [ + Checkbox::make('approve_new_organism') + ->label('Approve') + ->hidden(function (string $operation) { + return ! auth()->user()->isCurator() || $operation == 'create'; + }) + ->columnSpanFull(), + Grid::make() + ->schema(Organism::getForm( + requireUniqueName: false, + excludedNames: $linkedNames, + )), + ]; + }) ->reorderable(false) ->addActionLabel('Add New Organism') ->defaultItems(0) @@ -690,10 +693,11 @@ public static function form(Schema $schema): Schema Select::make('existing_citations') ->label('Existing') ->multiple() - ->options(function () { - if (self::$molecule) { - return self::$molecule->citations->where('title', '!=', null)->pluck('title', 'id')->toArray(); - } + ->options(function (Get $get, ?Report $record): array { + $molecule = static::resolveMolecule($record, $get); + $titles = $molecule?->citations->where('title', '!=', null)->pluck('title')->toArray() ?? []; + + return static::valueKeyedOptions(array_merge($titles, $get('existing_citations') ?? [])); }) ->disabled(function (Get $get, string $operation) { return ! $get('show_citation_existing') && $operation == 'edit'; @@ -1622,4 +1626,66 @@ public static function runSQLQueries(Report $record): void $molecule->save(); }); } + + /** + * @return array + */ + protected static function valueKeyedOptions(array $values): array + { + $values = array_values(array_unique(array_filter($values, fn ($value) => filled($value)))); + + if ($values === []) { + return []; + } + + return array_combine($values, $values); + } + + protected static function resolveMolecule(?Report $record = null, ?Get $get = null): ?Molecule + { + $identifier = static::resolveMoleculeIdentifier($record, $get); + + return $identifier ? Molecule::where('identifier', $identifier)->first() : null; + } + + protected static function resolveMoleculeIdentifier(?Report $record = null, ?Get $get = null): ?string + { + if ($record) { + $molIds = $record->mol_ids; + + if (is_array($molIds) && count($molIds) > 0) { + return $molIds[0]; + } + + if (is_string($molIds)) { + $decoded = json_decode($molIds, true); + + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + return $decoded[0] ?? null; + } + + return $molIds; + } + } + + if ($get) { + $molIds = $get('mol_ids'); + + if (is_array($molIds) && count($molIds) > 0) { + return $molIds[0]; + } + + $compoundId = $get('compound_id'); + + if (filled($compoundId)) { + return $compoundId; + } + } + + if (request()->has('compound_id')) { + return request()->compound_id; + } + + return null; + } } diff --git a/tests/Unit/ReportResourceMoleculeOptionsTest.php b/tests/Unit/ReportResourceMoleculeOptionsTest.php new file mode 100644 index 00000000..7433c44d --- /dev/null +++ b/tests/Unit/ReportResourceMoleculeOptionsTest.php @@ -0,0 +1,89 @@ +setAccessible(true); + + return $reflection->invoke(null, ...$args); + } + + public function test_value_keyed_options_uses_value_as_key_and_label(): void + { + $options = $this->callStatic('valueKeyedOptions', ['methane', 'CH4']); + + $this->assertSame(['methane' => 'methane', 'CH4' => 'CH4'], $options); + } + + public function test_value_keyed_options_filters_empty_values(): void + { + $options = $this->callStatic('valueKeyedOptions', ['methane', '', null]); + + $this->assertSame(['methane' => 'methane'], $options); + } + + public function test_value_keyed_options_deduplicates_values(): void + { + $options = $this->callStatic('valueKeyedOptions', ['methane', 'methane']); + + $this->assertSame(['methane' => 'methane'], $options); + } + + public function test_prefilled_select_state_matches_option_keys(): void + { + $synonyms = ['methane', 'CH4']; + $cas = ['74-82-8']; + + $synonymOptions = $this->callStatic('valueKeyedOptions', $synonyms); + $casOptions = $this->callStatic('valueKeyedOptions', $cas); + + $this->assertEmpty(array_diff($synonyms, array_keys($synonymOptions))); + $this->assertEmpty(array_diff($cas, array_keys($casOptions))); + } + + public function test_resolve_molecule_from_report_mol_ids(): void + { + $molecule = Molecule::create([ + 'identifier' => 'CNP0520383.0', + 'name' => 'old name', + 'synonyms' => ['CH4'], + 'cas' => ['74-82-8'], + ]); + + $report = new Report([ + 'mol_ids' => ['CNP0520383.0'], + 'title' => 'test', + ]); + + $resolved = $this->callStatic('resolveMolecule', $report, null); + + $this->assertTrue($resolved->is($molecule)); + } + + public function test_resolve_molecule_from_request_compound_id(): void + { + $molecule = Molecule::create([ + 'identifier' => 'CNP0520383.0', + 'name' => 'methane', + ]); + + request()->merge(['compound_id' => 'CNP0520383.0']); + + $resolved = $this->callStatic('resolveMolecule', null, null); + + $this->assertTrue($resolved->is($molecule)); + } +} From 9e1f3021f3976c06de41e2ca0ec87d75ad64ab50 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 21:43:49 +0200 Subject: [PATCH 3/7] fix(reports): resolve PHPStan errors in ReportResource Use explicit null checks for molecule synonyms/CAS options and remove unreachable is_string handling for mol_ids array cast. --- .../Dashboard/Resources/ReportResource.php | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/app/Filament/Dashboard/Resources/ReportResource.php b/app/Filament/Dashboard/Resources/ReportResource.php index 21e120d1..56fc80c9 100644 --- a/app/Filament/Dashboard/Resources/ReportResource.php +++ b/app/Filament/Dashboard/Resources/ReportResource.php @@ -542,7 +542,10 @@ public static function form(Schema $schema): Schema ->multiple() ->options(function (Get $get, ?Report $record): array { $molecule = static::resolveMolecule($record, $get); - $synonyms = array_merge($molecule?->synonyms ?? [], $get('existing_synonyms') ?? []); + $synonyms = array_merge( + $molecule === null ? [] : $molecule->synonyms, + $get('existing_synonyms') ?? [], + ); return static::valueKeyedOptions($synonyms); }) @@ -598,7 +601,7 @@ public static function form(Schema $schema): Schema ->options(function (Get $get, ?Report $record): array { $molecule = static::resolveMolecule($record, $get); $cas = array_merge( - array_values($molecule?->cas ?? []), + array_values($molecule === null ? [] : $molecule->cas), $get('existing_cas') ?? [], ); @@ -1656,16 +1659,6 @@ protected static function resolveMoleculeIdentifier(?Report $record = null, ?Get if (is_array($molIds) && count($molIds) > 0) { return $molIds[0]; } - - if (is_string($molIds)) { - $decoded = json_decode($molIds, true); - - if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { - return $decoded[0] ?? null; - } - - return $molIds; - } } if ($get) { From 097bf9a3f74ad710f4540d027bceb40ec9ea6158 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Tue, 9 Jun 2026 23:51:29 +0200 Subject: [PATCH 4/7] feat(geo): add country extraction and geocoding services Introduce rule-based country parsing from geo location names with Nominatim fallback, backed by config-driven aliases and unit tests. Refs Steinbeck-Lab/coconut#695 --- app/Services/GeoLocationCountryExtractor.php | 187 ++++++++++++++++++ app/Services/GeoLocationEnricher.php | 65 ++++++ app/Services/GeoLocationEnrichmentResult.php | 39 ++++ app/Services/GeoLocationService.php | 52 +++++ app/Services/NominatimGeocoder.php | 139 +++++++++++++ config/geo_location.php | 114 +++++++++++ config/services.php | 6 + .../Unit/GeoLocationCountryExtractorTest.php | 80 ++++++++ tests/Unit/NominatimGeocoderTest.php | 71 +++++++ 9 files changed, 753 insertions(+) create mode 100644 app/Services/GeoLocationCountryExtractor.php create mode 100644 app/Services/GeoLocationEnricher.php create mode 100644 app/Services/GeoLocationEnrichmentResult.php create mode 100644 app/Services/GeoLocationService.php create mode 100644 app/Services/NominatimGeocoder.php create mode 100644 config/geo_location.php create mode 100644 tests/Unit/GeoLocationCountryExtractorTest.php create mode 100644 tests/Unit/NominatimGeocoderTest.php diff --git a/app/Services/GeoLocationCountryExtractor.php b/app/Services/GeoLocationCountryExtractor.php new file mode 100644 index 00000000..f8b8a564 --- /dev/null +++ b/app/Services/GeoLocationCountryExtractor.php @@ -0,0 +1,187 @@ +|null */ + private ?array $lookup = null; + + /** @var list|null */ + private ?array $countryNamesByLength = null; + + public function extract(string $name): ?GeoLocationEnrichmentResult + { + $name = trim($name); + + if ($name === '') { + return null; + } + + $parts = array_values(array_filter( + array_map('trim', explode(',', $name)), + fn (string $part) => $part !== '' + )); + + for ($index = count($parts) - 1; $index >= 0; $index--) { + $match = $this->matchLookup($parts[$index]); + if ($match !== null) { + $county = $index > 0 ? $parts[$index - 1] : null; + + return $this->buildResult($match, $county, 'segment'); + } + } + + $wholeMatch = $this->matchLookup($name); + if ($wholeMatch !== null) { + return $this->buildResult($wholeMatch, null, 'whole'); + } + + if (preg_match('/\(([^)]+)\)\s*$/', $name, $matches)) { + $state = strtolower(trim($matches[1])); + if (in_array($state, $this->mexicanStates(), true)) { + return $this->buildResult( + ['code' => 'mx', 'name' => 'Mexico'], + trim(preg_replace('/\([^)]+\)\s*$/', '', $name) ?? $name), + 'parens_mx' + ); + } + } + + return $this->matchBySubstring($name); + } + + public function shouldSkipGeocoding(string $name): bool + { + return (bool) preg_match(config('geo_location.geocode_skip_pattern'), $name); + } + + /** + * @return array{code: string, name: string}|null + */ + private function matchLookup(string $value): ?array + { + $key = strtolower(trim($value)); + + return $this->lookup()[$key] ?? null; + } + + private function matchBySubstring(string $name): ?GeoLocationEnrichmentResult + { + $minLength = (int) config('geo_location.substring_min_length', 4); + + foreach ($this->countryNamesByLength() as $country) { + if ($country['len'] < $minLength) { + continue; + } + + if (stripos($name, $country['name']) !== false) { + return $this->buildResult( + ['code' => $country['code'], 'name' => $country['name']], + null, + 'substring' + ); + } + } + + return null; + } + + /** + * @param array{code: string, name: string} $match + */ + private function buildResult(array $match, ?string $county, string $method): GeoLocationEnrichmentResult + { + $countryData = country(strtolower($match['code'])); + + return new GeoLocationEnrichmentResult( + country: $countryData ? $countryData->getName() : $match['name'], + countryCode: strtoupper($match['code']), + county: $county, + flag: $countryData ? $countryData->getEmoji() : null, + method: $method, + ); + } + + /** + * @return array + */ + private function lookup(): array + { + if ($this->lookup !== null) { + return $this->lookup; + } + + $lookup = []; + + foreach (countries() as $code => $data) { + $common = $data['name']['common'] ?? (is_string($data['name'] ?? null) ? $data['name'] : null); + + if (! $common) { + continue; + } + + $lookup[strtolower(trim($common))] = [ + 'code' => strtolower((string) $code), + 'name' => $common, + ]; + } + + foreach (config('geo_location.aliases', []) as $alias => $code) { + $countryData = country(strtolower((string) $code)); + + if ($countryData) { + $lookup[strtolower((string) $alias)] = [ + 'code' => strtolower((string) $code), + 'name' => $countryData->getName(), + ]; + } + } + + $lookup['antarctica'] = ['code' => 'aq', 'name' => 'Antarctica']; + + $this->lookup = $lookup; + + return $this->lookup; + } + + /** + * @return list + */ + private function countryNamesByLength(): array + { + if ($this->countryNamesByLength !== null) { + return $this->countryNamesByLength; + } + + $names = []; + + foreach (countries() as $code => $data) { + $common = $data['name']['common'] ?? (is_string($data['name'] ?? null) ? $data['name'] : null); + + if (! $common) { + continue; + } + + $names[] = [ + 'name' => $common, + 'len' => strlen($common), + 'code' => strtolower((string) $code), + ]; + } + + usort($names, fn (array $a, array $b) => $b['len'] <=> $a['len']); + + $this->countryNamesByLength = $names; + + return $this->countryNamesByLength; + } + + /** + * @return list + */ + private function mexicanStates(): array + { + return config('geo_location.mexican_states', []); + } +} diff --git a/app/Services/GeoLocationEnricher.php b/app/Services/GeoLocationEnricher.php new file mode 100644 index 00000000..74c90028 --- /dev/null +++ b/app/Services/GeoLocationEnricher.php @@ -0,0 +1,65 @@ +extractor->extract($name); + + if ($result !== null) { + return $result; + } + } + + if (! $allowGeocoding) { + return null; + } + + return $this->geocoder->geocode($name); + } + + public function enrich( + GeoLocation $geoLocation, + bool $allowGeocoding = true, + bool $force = false, + bool $geocodeOnly = false, + ): ?GeoLocationEnrichmentResult { + if (! $force && filled($geoLocation->country_code)) { + return null; + } + + $result = $this->resolve($geoLocation->name, $allowGeocoding, $geocodeOnly); + + if ($result === null) { + return null; + } + + $this->applyResult($geoLocation, $result, $force); + $geoLocation->save(); + + return $result; + } + + public function applyResult(GeoLocation $geoLocation, GeoLocationEnrichmentResult $result, bool $force = false): void + { + foreach ($result->toModelAttributes() as $attribute => $value) { + if ($value === null) { + continue; + } + + if ($force || blank($geoLocation->{$attribute})) { + $geoLocation->{$attribute} = $value; + } + } + } +} diff --git a/app/Services/GeoLocationEnrichmentResult.php b/app/Services/GeoLocationEnrichmentResult.php new file mode 100644 index 00000000..ac526a89 --- /dev/null +++ b/app/Services/GeoLocationEnrichmentResult.php @@ -0,0 +1,39 @@ + + */ + public function toModelAttributes(): array + { + $attributes = [ + 'country' => $this->country, + 'country_code' => $this->countryCode, + 'county' => $this->county, + 'flag' => $this->flag, + ]; + + if ($this->latitude !== null) { + $attributes['latitude'] = $this->latitude; + } + + if ($this->longitude !== null) { + $attributes['longitude'] = $this->longitude; + } + + return $attributes; + } +} diff --git a/app/Services/GeoLocationService.php b/app/Services/GeoLocationService.php new file mode 100644 index 00000000..ccc435f4 --- /dev/null +++ b/app/Services/GeoLocationService.php @@ -0,0 +1,52 @@ +|null $cache + */ + public function findOrCreate(string $name, ?array &$cache = null, bool $allowGeocoding = true): GeoLocation + { + $name = trim($name); + + if ($name === '') { + throw new \InvalidArgumentException('Geo location name cannot be empty.'); + } + + if ($cache !== null && isset($cache[$name])) { + return GeoLocation::findOrFail($cache[$name]); + } + + $geoLocation = GeoLocation::firstOrCreate(['name' => $name]); + + if (blank($geoLocation->country_code)) { + $this->enricher->enrich($geoLocation, $allowGeocoding); + } + + if ($cache !== null) { + $cache[$name] = $geoLocation->id; + } + + return $geoLocation; + } + + /** + * @param array|null $cache + */ + public function findOrCreateId(string $name, ?array &$cache = null, bool $allowGeocoding = true): ?int + { + if (trim($name) === '') { + return null; + } + + return $this->findOrCreate($name, $cache, $allowGeocoding)->id; + } +} diff --git a/app/Services/NominatimGeocoder.php b/app/Services/NominatimGeocoder.php new file mode 100644 index 00000000..a8436f8a --- /dev/null +++ b/app/Services/NominatimGeocoder.php @@ -0,0 +1,139 @@ +extractor->shouldSkipGeocoding($name)) { + return null; + } + + $cacheKey = 'nominatim:'.md5($name); + $ttl = (int) config('geo_location.nominatim_cache_ttl', 60 * 60 * 24 * 30); + + return Cache::remember($cacheKey, $ttl, function () use ($name) { + return $this->requestGeocode($name); + }); + } + + private function requestGeocode(string $name): ?GeoLocationEnrichmentResult + { + $this->respectRateLimit(); + + $baseUrl = rtrim((string) config('services.nominatim.url'), '/'); + $userAgent = (string) config('services.nominatim.user_agent', 'COCONUT/1.0'); + + try { + $response = Http::withHeaders([ + 'User-Agent' => $userAgent, + 'Accept' => 'application/json', + ])->timeout(30)->get("{$baseUrl}/search", [ + 'q' => $name, + 'format' => 'json', + 'addressdetails' => 1, + 'limit' => 1, + ]); + + self::$lastRequestAt = microtime(true); + + if (! $response->successful()) { + Log::warning('Nominatim geocoding failed', [ + 'name' => $name, + 'status' => $response->status(), + ]); + + return null; + } + + $results = $response->json(); + + if (! is_array($results) || $results === []) { + return null; + } + + return $this->parseResult($results[0]); + } catch (\Throwable $exception) { + Log::warning('Nominatim geocoding exception', [ + 'name' => $name, + 'message' => $exception->getMessage(), + ]); + + return null; + } + } + + /** + * @param array $result + */ + private function parseResult(array $result): ?GeoLocationEnrichmentResult + { + $address = $result['address'] ?? []; + + if (! is_array($address)) { + return null; + } + + $countryCode = strtolower((string) ($address['country_code'] ?? '')); + + if ($countryCode === '') { + return null; + } + + $countryData = country($countryCode); + $countryName = (string) ($address['country'] ?? ($countryData ? $countryData->getName() : '')); + + if ($countryName === '') { + return null; + } + + $county = $address['state'] + ?? $address['county'] + ?? $address['region'] + ?? $address['province'] + ?? null; + + return new GeoLocationEnrichmentResult( + country: $countryName, + countryCode: strtoupper($countryCode), + county: is_string($county) ? $county : null, + flag: $countryData ? $countryData->getEmoji() : null, + latitude: isset($result['lat']) ? (float) $result['lat'] : null, + longitude: isset($result['lon']) ? (float) $result['lon'] : null, + method: 'geocoded', + ); + } + + private function respectRateLimit(): void + { + $delayMs = (int) config('services.nominatim.delay_ms', 1100); + + if (self::$lastRequestAt === null) { + return; + } + + $elapsedMs = (microtime(true) - self::$lastRequestAt) * 1000; + + if ($elapsedMs < $delayMs) { + usleep((int) (($delayMs - $elapsedMs) * 1000)); + } + } + + public static function resetRateLimitState(): void + { + self::$lastRequestAt = null; + } +} diff --git a/config/geo_location.php b/config/geo_location.php new file mode 100644 index 00000000..2e5aa319 --- /dev/null +++ b/config/geo_location.php @@ -0,0 +1,114 @@ + [ + 'usa' => 'US', + 'u.s.a.' => 'US', + 'u.s.a' => 'US', + 'uk' => 'GB', + 'u.k.' => 'GB', + 'south korea' => 'KR', + 'north korea' => 'KP', + 'russia' => 'RU', + 'czech republic' => 'CZ', + 'ivory coast' => 'CI', + 'vietnam' => 'VN', + 'bolivia' => 'BO', + 'venezuela' => 'VE', + 'iran' => 'IR', + 'syria' => 'SY', + 'tanzania' => 'TZ', + 'laos' => 'LA', + 'moldova' => 'MD', + 'western australia' => 'AU', + 'south australia' => 'AU', + 'northern territory' => 'AU', + 'queensland' => 'AU', + 'new south wales' => 'AU', + 'victoria' => 'AU', + 'tasmania' => 'AU', + 'canary islands' => 'ES', + 'hawaii' => 'US', + 'guam' => 'GU', + 'puerto rico' => 'PR', + 'french polynesia' => 'PF', + 'marshall islands' => 'MH', + 'micronesia' => 'FM', + 'palau' => 'PW', + ], + + /* + |-------------------------------------------------------------------------- + | Mexican state names (parentheses format: City(State)) + |-------------------------------------------------------------------------- + */ + 'mexican_states' => [ + 'estado de mexico', + 'ciudad de mexico', + 'distrito federal', + 'morelos', + 'puebla', + 'chihuahua', + 'guerrero', + 'veracruz', + 'sonora', + 'durango', + 'michoacan', + 'michoac?n', + 'nuevo leon', + 'nuevo le?n', + 'san luis potosi', + 'oaxaca', + 'jalisco', + 'yucatan', + 'yucat?n', + 'tabasco', + 'campeche', + 'quintana roo', + 'baja california', + 'sinaloa', + 'colima', + 'nayarit', + 'zacatecas', + 'aguascalientes', + 'guanajuato', + 'queretaro', + 'hidalgo', + 'tlaxcala', + 'chiapas', + 'tamaulipas', + 'coahuila', + ], + + /* + |-------------------------------------------------------------------------- + | Substring matching + |-------------------------------------------------------------------------- + */ + 'substring_min_length' => 4, + + /* + |-------------------------------------------------------------------------- + | Geocoding skip patterns + |-------------------------------------------------------------------------- + */ + 'geocode_skip_pattern' => '/not specified|no espec|unspecified|unknown/i', + + /* + |-------------------------------------------------------------------------- + | Nominatim cache TTL (seconds) + |-------------------------------------------------------------------------- + */ + 'nominatim_cache_ttl' => 60 * 60 * 24 * 30, + +]; diff --git a/config/services.php b/config/services.php index 748b8563..21c1593f 100644 --- a/config/services.php +++ b/config/services.php @@ -68,4 +68,10 @@ 'cas_key' => env('CAS_KEY'), ], + 'nominatim' => [ + 'url' => env('NOMINATIM_URL', 'https://nominatim.openstreetmap.org'), + 'user_agent' => env('NOMINATIM_USER_AGENT', 'COCONUT/1.0 (contact@nfdi4chem.de)'), + 'delay_ms' => env('NOMINATIM_DELAY_MS', 1100), + ], + ]; diff --git a/tests/Unit/GeoLocationCountryExtractorTest.php b/tests/Unit/GeoLocationCountryExtractorTest.php new file mode 100644 index 00000000..2b23908f --- /dev/null +++ b/tests/Unit/GeoLocationCountryExtractorTest.php @@ -0,0 +1,80 @@ +extractor = new GeoLocationCountryExtractor; + } + + public function test_extracts_country_from_last_comma_segment(): void + { + $result = $this->extractor->extract('Hiroshima Bay, Japan'); + + $this->assertNotNull($result); + $this->assertSame('Japan', $result->country); + $this->assertSame('JP', $result->countryCode); + $this->assertSame('segment', $result->method); + } + + public function test_extracts_country_from_india_segment(): void + { + $result = $this->extractor->extract('Tamil Nadu, India'); + + $this->assertNotNull($result); + $this->assertSame('India', $result->country); + $this->assertSame('IN', $result->countryCode); + } + + public function test_extracts_usa_alias_with_county(): void + { + $result = $this->extractor->extract('Carmel-by-the-Sea, California, USA'); + + $this->assertNotNull($result); + $this->assertSame('United States', $result->country); + $this->assertSame('US', $result->countryCode); + $this->assertSame('California', $result->county); + } + + public function test_extracts_mexico_from_parentheses_state(): void + { + $result = $this->extractor->extract('Tepeaca(Puebla)'); + + $this->assertNotNull($result); + $this->assertSame('Mexico', $result->country); + $this->assertSame('MX', $result->countryCode); + $this->assertSame('parens_mx', $result->method); + $this->assertSame('Tepeaca', $result->county); + } + + public function test_extracts_whole_name_country(): void + { + $result = $this->extractor->extract('Peru'); + + $this->assertNotNull($result); + $this->assertSame('Peru', $result->country); + $this->assertSame('PE', $result->countryCode); + $this->assertSame('segment', $result->method); + } + + public function test_returns_null_for_marine_region_without_country(): void + { + $this->assertNull($this->extractor->extract('Tyrrhenian Sea')); + } + + public function test_should_skip_geocoding_for_placeholder_names(): void + { + $this->assertTrue($this->extractor->shouldSkipGeocoding('Not specified(Ciudad de Mexico)')); + $this->assertTrue($this->extractor->shouldSkipGeocoding('No especified(No especified)')); + $this->assertFalse($this->extractor->shouldSkipGeocoding('Tyrrhenian Sea')); + } +} diff --git a/tests/Unit/NominatimGeocoderTest.php b/tests/Unit/NominatimGeocoderTest.php new file mode 100644 index 00000000..8c5b7cf7 --- /dev/null +++ b/tests/Unit/NominatimGeocoderTest.php @@ -0,0 +1,71 @@ + Http::response([ + [ + 'lat' => '42.6977', + 'lon' => '23.3219', + 'address' => [ + 'country_code' => 'bg', + 'country' => 'Bulgaria', + 'state' => 'Sofia City', + ], + ], + ]), + ]); + + $geocoder = new NominatimGeocoder(new GeoLocationCountryExtractor); + $result = $geocoder->geocode('Sofia, Bulgaria'); + + $this->assertNotNull($result); + $this->assertSame('Bulgaria', $result->country); + $this->assertSame('BG', $result->countryCode); + $this->assertSame('Sofia City', $result->county); + $this->assertSame('geocoded', $result->method); + $this->assertSame(42.6977, $result->latitude); + $this->assertSame(23.3219, $result->longitude); + } + + public function test_skips_geocoding_for_placeholder_names(): void + { + Http::fake(); + + $geocoder = new NominatimGeocoder(new GeoLocationCountryExtractor); + $result = $geocoder->geocode('Not specified(Ciudad de Mexico)'); + + $this->assertNull($result); + Http::assertNothingSent(); + } + + public function test_returns_null_when_nominatim_has_no_results(): void + { + Http::fake([ + 'nominatim.openstreetmap.org/search*' => Http::response([]), + ]); + + $geocoder = new NominatimGeocoder(new GeoLocationCountryExtractor); + $result = $geocoder->geocode('Tyrrhenian Sea'); + + $this->assertNull($result); + } +} From e4864f027776c3b1ccf98dc7206034e700089704 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Tue, 9 Jun 2026 23:51:32 +0200 Subject: [PATCH 5/7] feat(geo): enrich geo locations on import and via artisan command Centralize geo location creation in GeoLocationService and add coconut:enrich-geo-locations for backfilling country metadata. Refs Steinbeck-Lab/coconut#695 --- app/Console/Commands/EnrichGeoLocations.php | 115 ++++++++++++++++++ .../Commands/ImportOrganismMetadata.php | 45 +------ .../ImportEntriesReferencesAuto.php | 36 +----- .../Dashboard/Resources/ReportResource.php | 3 +- app/Jobs/ImportEntry.php | 4 +- app/Jobs/ImportEntryAuto.php | 9 +- 6 files changed, 136 insertions(+), 76 deletions(-) create mode 100644 app/Console/Commands/EnrichGeoLocations.php diff --git a/app/Console/Commands/EnrichGeoLocations.php b/app/Console/Commands/EnrichGeoLocations.php new file mode 100644 index 00000000..3374f017 --- /dev/null +++ b/app/Console/Commands/EnrichGeoLocations.php @@ -0,0 +1,115 @@ +option('dry-run'); + $rulesOnly = (bool) $this->option('rules-only'); + $geocodeOnly = (bool) $this->option('geocode-only'); + $force = (bool) $this->option('force'); + $limit = $this->option('limit') !== null ? (int) $this->option('limit') : null; + + $query = GeoLocation::query()->orderBy('id'); + + if (! $force) { + $query->whereNull('country_code'); + } + + if ($limit !== null) { + $query->limit($limit); + } + + $records = $query->get(); + $total = $records->count(); + + if ($total === 0) { + $this->info('No geo locations to process.'); + + return self::SUCCESS; + } + + $this->info("Processing {$total} geo location(s)..."); + + $stats = [ + 'segment' => 0, + 'whole' => 0, + 'parens_mx' => 0, + 'substring' => 0, + 'geocoded' => 0, + 'failed' => 0, + 'skipped' => 0, + ]; + + $allowGeocoding = ! $rulesOnly; + + $bar = $this->output->createProgressBar($total); + $bar->start(); + + foreach ($records as $geoLocation) { + if (! $force && filled($geoLocation->country_code)) { + $stats['skipped']++; + $bar->advance(); + + continue; + } + + if ($dryRun) { + $result = $enricher->resolve( + $geoLocation->name, + allowGeocoding: $allowGeocoding, + geocodeOnly: $geocodeOnly, + ); + } else { + $result = $enricher->enrich( + $geoLocation, + allowGeocoding: $allowGeocoding, + force: $force, + geocodeOnly: $geocodeOnly, + ); + } + + if ($result !== null) { + $method = $result->method === 'geocoded' ? 'geocoded' : $result->method; + $stats[$method] = ($stats[$method] ?? 0) + 1; + } else { + $stats['failed']++; + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + + $matched = $stats['segment'] + $stats['whole'] + $stats['parens_mx'] + $stats['substring'] + $stats['geocoded']; + + $this->table( + ['Method', 'Count'], + collect($stats)->map(fn (int $count, string $method) => [$method, $count])->values()->all(), + ); + + $this->info("Matched: {$matched} / {$total}"); + + if ($dryRun) { + $this->warn('Dry run only — no records were updated.'); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ImportOrganismMetadata.php b/app/Console/Commands/ImportOrganismMetadata.php index 1e0ae7a2..dd2053b6 100644 --- a/app/Console/Commands/ImportOrganismMetadata.php +++ b/app/Console/Commands/ImportOrganismMetadata.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Models\Entry; +use App\Services\GeoLocationService; use Illuminate\Console\Command; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; @@ -1300,45 +1301,11 @@ protected function findOrCreateGeoLocation(string $name): ?int return null; } - if (isset($this->geoLocationCache[$name])) { - return $this->geoLocationCache[$name]; - } - - $existing = DB::selectOne('SELECT id FROM geo_locations WHERE name = ?', [$name]); - if ($existing) { - $this->geoLocationCache[$name] = $existing->id; - - return $existing->id; - } - - try { - $id = DB::table('geo_locations')->insertGetId([ - 'name' => $name, - 'created_at' => now(), - 'updated_at' => now(), - ]); - $this->geoLocationCache[$name] = $id; - - return $id; - } catch (QueryException $e) { - if (! $this->isUniqueViolation($e)) { - Log::error('Unexpected DB error in geo_locations insert (not unique violation)', [ - 'sqlstate' => $e->errorInfo[0] ?? null, - 'msg' => $e->getMessage(), - ]); - throw $e; - } - - // Handle race condition - unique violation - usleep(50000); - $existing = DB::selectOne('SELECT id FROM geo_locations WHERE name = ?', [$name]); - if ($existing) { - $this->geoLocationCache[$name] = $existing->id; - - return $existing->id; - } - throw $e; - } + return app(GeoLocationService::class)->findOrCreateId( + $name, + $this->geoLocationCache, + allowGeocoding: false, + ); } protected function findOrCreateEcosystem(string $name, ?int $geoLocationId = null): ?int diff --git a/app/Console/Commands/SubmissionsAutoProcess/ImportEntriesReferencesAuto.php b/app/Console/Commands/SubmissionsAutoProcess/ImportEntriesReferencesAuto.php index 723df002..80c25763 100644 --- a/app/Console/Commands/SubmissionsAutoProcess/ImportEntriesReferencesAuto.php +++ b/app/Console/Commands/SubmissionsAutoProcess/ImportEntriesReferencesAuto.php @@ -2,6 +2,7 @@ namespace App\Console\Commands\SubmissionsAutoProcess; +use App\Services\GeoLocationService; use Illuminate\Console\Command; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; @@ -1110,36 +1111,11 @@ protected function findOrCreateGeoLocation(string $name): ?int return null; } - if (isset($this->geoLocationCache[$name])) { - return $this->geoLocationCache[$name]; - } - - $existing = DB::selectOne('SELECT id FROM geo_locations WHERE name = ?', [$name]); - if ($existing) { - $this->geoLocationCache[$name] = $existing->id; - - return $existing->id; - } - - try { - $id = DB::table('geo_locations')->insertGetId([ - 'name' => $name, - 'created_at' => now(), - 'updated_at' => now(), - ]); - $this->geoLocationCache[$name] = $id; - - return $id; - } catch (QueryException $e) { - usleep(50000); - $existing = DB::selectOne('SELECT id FROM geo_locations WHERE name = ?', [$name]); - if ($existing) { - $this->geoLocationCache[$name] = $existing->id; - - return $existing->id; - } - throw $e; - } + return app(GeoLocationService::class)->findOrCreateId( + $name, + $this->geoLocationCache, + allowGeocoding: false, + ); } protected function findOrCreateEcosystem(string $name, ?int $geoLocationId = null): ?int diff --git a/app/Filament/Dashboard/Resources/ReportResource.php b/app/Filament/Dashboard/Resources/ReportResource.php index 56fc80c9..6f6666bd 100644 --- a/app/Filament/Dashboard/Resources/ReportResource.php +++ b/app/Filament/Dashboard/Resources/ReportResource.php @@ -24,6 +24,7 @@ use App\Models\Report; use App\Models\ReportUser; use App\Models\User; +use App\Services\GeoLocationService; use Closure; use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; @@ -1545,7 +1546,7 @@ public static function runSQLQueries(Report $record): void if (! empty(self::$overall_changes['geo_location_changes']['add'])) { $geoLocations = explode(',', self::$overall_changes['geo_location_changes']['add']); foreach ($geoLocations as $newLocation) { - $geo_location = GeoLocation::firstOrCreate(['name' => $newLocation]); + $geo_location = app(GeoLocationService::class)->findOrCreate(trim($newLocation)); $molecule->auditAttach('geo_locations', $geo_location); } } diff --git a/app/Jobs/ImportEntry.php b/app/Jobs/ImportEntry.php index 4175e884..b79da116 100644 --- a/app/Jobs/ImportEntry.php +++ b/app/Jobs/ImportEntry.php @@ -3,9 +3,9 @@ namespace App\Jobs; use App\Models\Citation; -use App\Models\GeoLocation; use App\Models\Molecule; use App\Models\Organism; +use App\Services\GeoLocationService; use Exception; use Illuminate\Bus\Batchable; use Illuminate\Bus\Queueable; @@ -231,7 +231,7 @@ public function saveGeoLocationDetails($geo_location, $molecule) $locations = explode('|', $this->entry->locations); $i = 0; foreach ($geo_locations as $geo_location) { - $geolocationModel = GeoLocation::firstOrCreate(['name' => $geo_location]); + $geolocationModel = app(GeoLocationService::class)->findOrCreate($geo_location, allowGeocoding: false); $locationsNames = array_key_exists($i, $locations) ? $locations[$i] : ''; $molecule->geo_locations()->syncWithoutDetaching([$geolocationModel->id => ['locations' => $locationsNames]]); $i = $i + 1; diff --git a/app/Jobs/ImportEntryAuto.php b/app/Jobs/ImportEntryAuto.php index 593fff1c..b4c0f8c5 100644 --- a/app/Jobs/ImportEntryAuto.php +++ b/app/Jobs/ImportEntryAuto.php @@ -4,11 +4,11 @@ use App\Models\Citation; use App\Models\Ecosystem; -use App\Models\GeoLocation; use App\Models\Molecule; use App\Models\MoleculeOrganism; use App\Models\Organism; use App\Models\SampleLocation; +use App\Services\GeoLocationService; use Exception; use Illuminate\Bus\Batchable; use Illuminate\Bus\Queueable; @@ -248,9 +248,10 @@ public function saveOrganismDetails($organisms, $molecule) foreach ($organism['locations'] as $location) { $geo_location_ids = []; // Reset for each location - $geoLocationModel = GeoLocation::firstOrCreate([ - 'name' => $location['name'], - ]); + $geoLocationModel = app(GeoLocationService::class)->findOrCreate( + $location['name'], + allowGeocoding: false, + ); // Connect organism to geo location directly // $organismModel->geoLocations()->syncWithoutDetaching([$geoLocationModel->id]); From 1e897ad943fb8bb59cce1d799b7c2853a6d57c21 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Tue, 9 Jun 2026 23:51:36 +0200 Subject: [PATCH 6/7] feat(globe): add choropleth world map for geo location coverage Add a public /globe page with country-level molecule, organism, and geo-location stats using globe.gl choropleth visualization. Refs Steinbeck-Lab/coconut#695 --- app/Livewire/GeoGlobe.php | 28 + app/Services/GeoLocationGlobeStatsService.php | 85 +++ app/Support/Csp/Policies/CoconutPolicy.php | 3 +- package-lock.json | 512 +++++++++++++++++- package.json | 6 +- resources/js/geo-globe.js | 207 +++++++ resources/views/livewire/geo-globe.blade.php | 93 ++++ resources/views/livewire/header.blade.php | 12 +- routes/web.php | 2 + vite.config.js | 1 + 10 files changed, 944 insertions(+), 5 deletions(-) create mode 100644 app/Livewire/GeoGlobe.php create mode 100644 app/Services/GeoLocationGlobeStatsService.php create mode 100644 resources/js/geo-globe.js create mode 100644 resources/views/livewire/geo-globe.blade.php diff --git a/app/Livewire/GeoGlobe.php b/app/Livewire/GeoGlobe.php new file mode 100644 index 00000000..80084d38 --- /dev/null +++ b/app/Livewire/GeoGlobe.php @@ -0,0 +1,28 @@ +getCountryStats(); + + $this->countryStats = $data['countries']; + $this->totals = $data['totals']; + } + + public function render() + { + return view('livewire.geo-globe'); + } +} diff --git a/app/Services/GeoLocationGlobeStatsService.php b/app/Services/GeoLocationGlobeStatsService.php new file mode 100644 index 00000000..aa686b2a --- /dev/null +++ b/app/Services/GeoLocationGlobeStatsService.php @@ -0,0 +1,85 @@ +, + * totals: array{ + * molecules: int, + * organisms: int, + * geo_locations: int, + * countries: int + * } + * } + */ + public function getCountryStats(): array + { + return Cache::flexible('stats.geo_globe.countries', [3600, 7200], function () { + $rows = DB::select(" + SELECT + UPPER(gl.country_code) AS country_code, + MAX(gl.country) AS country, + MAX(gl.flag) AS flag, + COUNT(DISTINCT glm.molecule_id) AS molecules, + COUNT(DISTINCT mo.organism_id) AS organisms, + COUNT(DISTINCT gl.id) AS geo_locations + FROM geo_locations gl + INNER JOIN geo_location_molecule glm ON glm.geo_location_id = gl.id + LEFT JOIN molecule_organism mo ON mo.molecule_id = glm.molecule_id + AND (mo.geo_location_id = gl.id OR mo.geo_location_id IS NULL) + WHERE gl.country_code IS NOT NULL + AND gl.country_code <> '' + GROUP BY UPPER(gl.country_code) + ORDER BY molecules DESC + "); + + $countries = array_map(function ($row) { + return [ + 'country_code' => $row->country_code, + 'country' => $row->country, + 'flag' => $row->flag, + 'molecules' => (int) $row->molecules, + 'organisms' => (int) $row->organisms, + 'geo_locations' => (int) $row->geo_locations, + ]; + }, $rows); + + return [ + 'countries' => $countries, + 'totals' => [ + 'molecules' => (int) DB::table('geo_location_molecule as glm') + ->join('geo_locations as gl', 'gl.id', '=', 'glm.geo_location_id') + ->whereNotNull('gl.country_code') + ->where('gl.country_code', '<>', '') + ->selectRaw('COUNT(DISTINCT glm.molecule_id) as aggregate') + ->value('aggregate'), + 'organisms' => (int) DB::table('molecule_organism as mo') + ->join('geo_location_molecule as glm', 'glm.molecule_id', '=', 'mo.molecule_id') + ->join('geo_locations as gl', 'gl.id', '=', 'glm.geo_location_id') + ->whereNotNull('gl.country_code') + ->where('gl.country_code', '<>', '') + ->selectRaw('COUNT(DISTINCT mo.organism_id) as aggregate') + ->value('aggregate'), + 'geo_locations' => (int) DB::table('geo_locations') + ->whereNotNull('country_code') + ->where('country_code', '<>', '') + ->count(), + 'countries' => count($countries), + ], + ]; + }); + } +} diff --git a/app/Support/Csp/Policies/CoconutPolicy.php b/app/Support/Csp/Policies/CoconutPolicy.php index f1ea62de..fca47fe9 100644 --- a/app/Support/Csp/Policies/CoconutPolicy.php +++ b/app/Support/Csp/Policies/CoconutPolicy.php @@ -37,7 +37,8 @@ public function configure(Policy $policy): void ->add(Directive::FONT, ['https://fonts.bunny.net']) ->add(Directive::SCRIPT, ['https://matomo.nfdi4chem.de', 'https://cdn.jsdelivr.net', 'https://unpkg.com']) ->add(Directive::CONNECT, ['https://matomo.nfdi4chem.de', 'https://unpkg.com']) - ->add(Directive::IMG, 'https://matomo.nfdi4chem.de'); + ->add(Directive::IMG, 'https://matomo.nfdi4chem.de') + ->add(Directive::IMG, 'https://unpkg.com'); // Add Coconut-specific external sources $this->addCoconutSources($policy); diff --git a/package-lock.json b/package-lock.json index cc91d503..ad84431d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,11 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "openchemlib": "^8.13.0" + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.1.0", + "globe.gl": "^2.46.1", + "openchemlib": "^8.13.0", + "three": "^0.184.0" }, "devDependencies": { "@tailwindcss/forms": "^0.5.9", @@ -278,6 +282,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.28.5", "dev": true, @@ -1502,11 +1515,66 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@turf/boolean-point-in-polygon": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.5.tgz", + "integrity": "sha512-ba7+B0wzaS9GtERZOoXUZ6oW8IcIJHNQZf3c+tiD9ESjcsPO1Q/4qIJGTKl92nBLhhracHJxMWBM/U6hAVkaRg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@turf/invariant": "7.3.5", + "@types/geojson": "^7946.0.10", + "point-in-polygon-hao": "^1.1.0", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.5.tgz", + "integrity": "sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/invariant": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.3.5.tgz", + "integrity": "sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "dev": true, @@ -1788,6 +1856,15 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/algoliasearch": { "version": "5.46.2", "dev": true, @@ -2018,6 +2095,189 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-voronoi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/d3-geo-voronoi/-/d3-geo-voronoi-2.1.0.tgz", + "integrity": "sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-delaunay": "6", + "d3-geo": "3", + "d3-tricontour": "1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-tricontour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-tricontour/-/d3-tricontour-1.1.0.tgz", + "integrity": "sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==", + "license": "ISC", + "dependencies": { + "d3-delaunay": "6", + "d3-scale": "4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-bind-mapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz", + "integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==", + "license": "MIT", + "dependencies": { + "accessor-fn": "1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "dev": true, @@ -2069,6 +2329,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "dev": true, @@ -2195,6 +2461,20 @@ "dev": true, "license": "MIT" }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/focus-trap": { "version": "7.7.1", "dev": true, @@ -2249,6 +2529,15 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/frame-ticker": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/frame-ticker/-/frame-ticker-1.0.3.tgz", + "integrity": "sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==", + "license": "MIT", + "dependencies": { + "simplesignal": "^2.1.6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2307,6 +2596,23 @@ "node": ">= 0.4" } }, + "node_modules/globe.gl": { + "version": "2.46.1", + "resolved": "https://registry.npmjs.org/globe.gl/-/globe.gl-2.46.1.tgz", + "integrity": "sha512-h+OvX52EBIPLtM0/2JkM+JZ9gPAhPJ4y3+hxUwD5Ey/O0Zk2ockuTiJ71bZbnNBGmNiIZzA5Vr3TMT0b3d35IQ==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "kapsule": "^1.16", + "three": ">=0.179 <1", + "three-globe": "^2.45", + "three-render-objects": "^1.41" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/gopd": { "version": "1.2.0", "dev": true, @@ -2325,6 +2631,17 @@ "dev": true, "license": "ISC" }, + "node_modules/h3-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", + "integrity": "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "dev": true, @@ -2409,6 +2726,24 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-what": { "version": "5.5.0", "dev": true, @@ -2430,6 +2765,18 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/laravel-vite-plugin": { "version": "1.3.0", "dev": true, @@ -2709,6 +3056,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "dev": true, @@ -2928,6 +3281,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/point-in-polygon-hao": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz", + "integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==", + "license": "MIT", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/postcss": { "version": "8.5.6", "dev": true, @@ -3056,7 +3430,6 @@ "version": "10.28.2", "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", - "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -3117,6 +3490,12 @@ "dev": true, "license": "MIT" }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.55.1", "dev": true, @@ -3181,6 +3560,12 @@ "@types/hast": "^3.0.4" } }, + "node_modules/simplesignal": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/simplesignal/-/simplesignal-2.1.7.tgz", + "integrity": "sha512-PEo2qWpUke7IMhlqiBxrulIFvhJRLkl1ih52Rwa+bPjzhJepcd4GIjn2RiQmFSx3dQvsEAgF0/lXMwMN7vODaA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "dev": true, @@ -3256,6 +3641,123 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, + "node_modules/three-conic-polygon-geometry": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/three-conic-polygon-geometry/-/three-conic-polygon-geometry-2.1.3.tgz", + "integrity": "sha512-7KYcgd7rYg91L8AnCEddeyhw4qlv9+8NAI0luHq7QGt26icWUGNCsvLyD+u6FPKjP+un3zGBi4JGzqgGpmBSWA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "^7.2", + "d3-array": "1 - 3", + "d3-geo": "1 - 3", + "d3-geo-voronoi": "2", + "d3-scale": "1 - 4", + "delaunator": "5", + "earcut": "3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.72.0" + } + }, + "node_modules/three-geojson-geometry": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/three-geojson-geometry/-/three-geojson-geometry-2.1.1.tgz", + "integrity": "sha512-dC7bF3ri1goDcihYhzACHOBQqu7YNNazYLa2bSydVIiJUb3jDFojKSy+gNj2pMkqZNSVjssSmdY9zlmnhEpr1w==", + "license": "MIT", + "dependencies": { + "d3-geo": "1 - 3", + "d3-interpolate": "1 - 3", + "earcut": "3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.72.0" + } + }, + "node_modules/three-globe": { + "version": "2.45.2", + "resolved": "https://registry.npmjs.org/three-globe/-/three-globe-2.45.2.tgz", + "integrity": "sha512-3qJE2LAdyHsUPt02mgMRc+PG3j9kGEA0fUYrwKPGIVtvMR1XjDn9hCXu31AWocdgHOFcXkrRVz7jJZzTIvR0eQ==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "d3-array": "3", + "d3-color": "3", + "d3-geo": "3", + "d3-interpolate": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "data-bind-mapper": "1", + "frame-ticker": "1", + "h3-js": "4", + "index-array-by": "1", + "kapsule": "^1.16", + "three-conic-polygon-geometry": "2", + "three-geojson-geometry": "2", + "three-slippy-map-globe": "1", + "tinycolor2": "1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.154" + } + }, + "node_modules/three-render-objects": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/three-render-objects/-/three-render-objects-1.42.0.tgz", + "integrity": "sha512-KYfkPrYGEbIK8ChFocWqOF1aAN80FBUBWVYB8mB2oBpVuVN+52FvvngVYB5ieFANQu7Rt21rPYZ/xKaAgVWWRQ==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "float-tooltip": "^1.7", + "kapsule": "^1.16", + "polished": "4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.179" + } + }, + "node_modules/three-slippy-map-globe": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/three-slippy-map-globe/-/three-slippy-map-globe-1.0.6.tgz", + "integrity": "sha512-PCUR+X+1kYFYtQBf8+b/ct8xBHtnkeu7FItRYBeFxyIe3ksnGuLi0H9RAxAfVSSUsZVbKIKNz9q1atEjynrrkg==", + "license": "MIT", + "dependencies": { + "d3-geo": "1 - 3", + "d3-octree": "^1.1", + "d3-scale": "1 - 4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.154" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/trim-lines": { "version": "3.0.1", "dev": true, @@ -3265,6 +3767,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/unist-util-is": { "version": "6.0.1", "dev": true, diff --git a/package.json b/package.json index d09b327a..0bd3ef73 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,11 @@ "vitepress": "^1.2.2" }, "dependencies": { - "openchemlib": "^8.13.0" + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.1.0", + "globe.gl": "^2.46.1", + "openchemlib": "^8.13.0", + "three": "^0.184.0" }, "name": "coconut", "version": "1.0.0", diff --git a/resources/js/geo-globe.js b/resources/js/geo-globe.js new file mode 100644 index 00000000..205e938f --- /dev/null +++ b/resources/js/geo-globe.js @@ -0,0 +1,207 @@ +import Globe from 'globe.gl'; +import { scaleSequentialSqrt } from 'd3-scale'; +import { interpolateYlOrRd } from 'd3-scale-chromatic'; + +const GEOJSON_URL = + 'https://unpkg.com/globe.gl/example/datasets/ne_110m_admin_0_countries.geojson'; + +const TEXTURES = { + globe: 'https://unpkg.com/three-globe/example/img/earth-blue-marble.jpg', + bump: 'https://unpkg.com/three-globe/example/img/earth-topology.png', +}; + +function readPayload() { + const element = document.getElementById('geo-globe-data'); + + if (!element) { + return { countries: [] }; + } + + try { + return JSON.parse(element.textContent); + } catch { + return { countries: [] }; + } +} + +function buildLookup(countries) { + return Object.fromEntries( + countries.map((country) => [country.country_code, country]), + ); +} + +function metricValue(country, metric) { + if (!country) { + return 0; + } + + return country[metric] ?? 0; +} + +function formatNumber(value) { + return new Intl.NumberFormat().format(value); +} + +export function initGeoGlobe() { + const root = document.getElementById('geo-globe-root'); + + if (!root || root.dataset.initialized === 'true') { + return; + } + + root.dataset.initialized = 'true'; + + const payload = readPayload(); + const lookup = buildLookup(payload.countries ?? []); + let activeMetric = 'molecules'; + let colorScale = () => 'rgba(200, 200, 200, 0.35)'; + + const metricButtons = root.querySelectorAll('[data-metric]'); + const hoverName = root.querySelector('[data-hover-name]'); + const hoverMolecules = root.querySelector('[data-hover-molecules]'); + const hoverOrganisms = root.querySelector('[data-hover-organisms]'); + const hoverGeoLocations = root.querySelector('[data-hover-geo-locations]'); + const hoverPanel = root.querySelector('[data-hover-panel]'); + + const globeContainer = root.querySelector('#globeViz'); + const globe = Globe({ animateIn: true })(globeContainer) + .globeImageUrl(TEXTURES.globe) + .bumpImageUrl(TEXTURES.bump) + .backgroundColor('#ffffff') + .showAtmosphere(false) + .polygonCapCurvatureResolution(3) + .polygonSideColor(() => 'rgba(0, 80, 120, 0.25)') + .polygonStrokeColor(() => '#111') + .polygonLabel(() => '') + .onPolygonHover((hoverD) => { + globeContainer.style.cursor = hoverD ? 'pointer' : null; + + if (!hoverD) { + if (hoverPanel) { + hoverPanel.classList.add('opacity-40'); + } + + return; + } + + const code = hoverD.properties?.ISO_A2; + const stats = lookup[code]; + + if (hoverPanel) { + hoverPanel.classList.remove('opacity-40'); + } + + if (hoverName) { + hoverName.textContent = stats?.country ?? hoverD.properties?.ADMIN ?? 'Unknown'; + } + + if (hoverMolecules) { + hoverMolecules.textContent = formatNumber(metricValue(stats, 'molecules')); + } + + if (hoverOrganisms) { + hoverOrganisms.textContent = formatNumber(metricValue(stats, 'organisms')); + } + + if (hoverGeoLocations) { + hoverGeoLocations.textContent = formatNumber(metricValue(stats, 'geo_locations')); + } + }); + + function rebuildColorScale(countries, metric) { + const values = countries.map((country) => country[metric] ?? 0); + const maxValue = Math.max(...values, 1); + + colorScale = scaleSequentialSqrt(interpolateYlOrRd).domain([0, maxValue]); + } + + function polygonHeight(feature) { + const code = feature.properties?.ISO_A2; + const value = metricValue(lookup[code], activeMetric); + const maxValue = Math.max( + ...(payload.countries ?? []).map((country) => country[activeMetric] ?? 0), + 1, + ); + + return value ? 0.02 + (value / maxValue) * 0.12 : 0.01; + } + + function applyMetric(metric) { + activeMetric = metric; + + metricButtons.forEach((button) => { + const isActive = button.dataset.metric === metric; + button.classList.toggle('bg-white', isActive); + button.classList.toggle('shadow-sm', isActive); + button.classList.toggle('text-gray-900', isActive); + button.classList.toggle('text-gray-500', !isActive); + button.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); + + rebuildColorScale(payload.countries ?? [], metric); + + globe.polygonCapColor((feature) => { + const code = feature.properties?.ISO_A2; + const stats = lookup[code]; + const value = metricValue(stats, activeMetric); + + if (!value) { + return 'rgba(120, 120, 120, 0.25)'; + } + + return colorScale(value); + }); + + globe.polygonAltitude(polygonHeight); + + globe.polygonLabel(({ properties: feature }) => { + const code = feature.ISO_A2; + const stats = lookup[code]; + + if (!stats) { + return `
${feature.ADMIN}
No COCONUT data
`; + } + + return ` +
+ ${stats.flag ?? ''} ${stats.country}
+ Molecules: ${formatNumber(stats.molecules)}
+ Organisms: ${formatNumber(stats.organisms)}
+ Geo locations: ${formatNumber(stats.geo_locations)} +
+ `; + }); + } + + metricButtons.forEach((button) => { + button.addEventListener('click', () => applyMetric(button.dataset.metric)); + }); + + fetch(GEOJSON_URL) + .then((response) => response.json()) + .then((countries) => { + globe + .polygonsData( + countries.features.filter( + (feature) => feature.properties?.ISO_A2 !== 'AQ', + ), + ) + .polygonAltitude(polygonHeight); + + applyMetric('molecules'); + }) + .catch((error) => { + console.error('Failed to load globe country data', error); + }); + + const resize = () => { + globe.width(globeContainer.clientWidth); + globe.height(globeContainer.clientHeight); + }; + + resize(); + window.addEventListener('resize', resize); +} + +document.addEventListener('DOMContentLoaded', initGeoGlobe); +document.addEventListener('livewire:navigated', initGeoGlobe); diff --git a/resources/views/livewire/geo-globe.blade.php b/resources/views/livewire/geo-globe.blade.php new file mode 100644 index 00000000..c9d0e191 --- /dev/null +++ b/resources/views/livewire/geo-globe.blade.php @@ -0,0 +1,93 @@ +
+ @vite(['resources/js/geo-globe.js']) + + + +
+
+
+
+
Countries
+
{{ number_format($totals['countries']) }}
+
+
+
Molecules
+
{{ number_format($totals['molecules']) }}
+
+
+
Organisms
+
{{ number_format($totals['organisms']) }}
+
+
+
Geo locations
+
{{ number_format($totals['geo_locations']) }}
+
+
+ +
+ + + +
+
+ +
+
+
+
+ +
+
+

Hovered country

+

Move over a country

+
+
+
Molecules
+
—
+
+
+
Organisms
+
—
+
+
+
Geo locations
+
—
+
+
+
+ +
+

Top countries by molecules

+
    + @foreach (collect($countryStats)->take(10) as $country) +
  1. + + @if ($country['flag']) + {{ $country['flag'] }} + @endif + {{ $country['country'] }} + + {{ number_format($country['molecules']) }} +
  2. + @endforeach +
+
+
+
+ +

+ Globe visualization powered by + globe.gl. + Only geo locations with a resolved country code are included. +

+
+
diff --git a/resources/views/livewire/header.blade.php b/resources/views/livewire/header.blade.php index 34654456..c723d526 100644 --- a/resources/views/livewire/header.blade.php +++ b/resources/views/livewire/header.blade.php @@ -1,4 +1,4 @@ -
+