From 74b5a0dd22c8a9cc6106cb680b2a13a7db145ab1 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Sat, 11 Jul 2026 21:57:24 +0000 Subject: [PATCH 01/31] increase maxlimits for selections validation --- docroot/schema/event_layers.schema.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docroot/schema/event_layers.schema.json b/docroot/schema/event_layers.schema.json index dab6845f..cb7c9563 100644 --- a/docroot/schema/event_layers.schema.json +++ b/docroot/schema/event_layers.schema.json @@ -53,7 +53,7 @@ }, "event_instances": { "type": "array", - "maxItems": 30, + "maxItems": 200, "items": { "type": "string", "pattern": "^[a-zA-Z0-9]{2,3}--[a-zA-Z0-9_ +\\\\()\\-]{1,50}--[a-zA-Z0-9_.\\()+]+$", @@ -66,6 +66,7 @@ }, "layers_v2": { "type": "array", + "maxItems": 200, "items": { "type": "string", "minLength": 1, From 46dd5fc38a7130da99c0a34f9c08cfeadedb4755 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Sat, 11 Jul 2026 22:07:39 +0000 Subject: [PATCH 02/31] enable wsa source and new event_type magnetic connectivity colors and 2 letter eventpin --- src/Event/Api/EventsApi.php | 2 +- src/Event/EventSelections.php | 4 ++++ src/Image/Composite/HelioviewerCompositeImage.php | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index 0e7907c8..c550c16a 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -21,7 +21,7 @@ class EventsApi implements EventsApiInterface { /** Known event sources */ - public const VALID_SOURCES = ['HEK', 'CCMC', 'RHESSI']; + public const VALID_SOURCES = ['HEK', 'CCMC', 'RHESSI', 'WSA']; /** Fallback used when the HV_* config constant is not defined. */ private const DEFAULT_MAX_CHUNK_SIZE = 150; diff --git a/src/Event/EventSelections.php b/src/Event/EventSelections.php index abbf1775..ddb5733d 100644 --- a/src/Event/EventSelections.php +++ b/src/Event/EventSelections.php @@ -62,6 +62,10 @@ class EventSelections implements ArrayAccess, Countable, IteratorAggregate 'RHESSI' => [ 'F2' => 'Solar Flares', ], + 'WSA' => [ + 'MC' => 'Magnetic Connectivity', + 'CH' => 'Coronal Hole', + ], ]; private array $selections; diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index e0e80f3c..987bebe5 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -61,6 +61,7 @@ class Image_Composite_HelioviewerCompositeImage { 'SR' => '9DA4FF', 'HY' => 'CA89FF', 'NR' => 'FFD391', + 'MC' => 'B0C4FF', ]; /** From 5e3df135871cb49949cb47d1fc3efbd19573bbe1 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Sat, 11 Jul 2026 22:32:30 +0000 Subject: [PATCH 03/31] implement eventsDataCoverage , simpler endpoint to read eventtimeline --- .../schema/events_data_coverage.schema.json | 21 +++++++++ src/Actions.php | 1 + src/Event/EventSelections.php | 13 ++++++ src/Event/Timeline/Timeline.php | 27 ++++++++++++ src/Module/WebClient.php | 44 +++++++++++++++++++ 5 files changed, 106 insertions(+) create mode 100644 docroot/schema/events_data_coverage.schema.json diff --git a/docroot/schema/events_data_coverage.schema.json b/docroot/schema/events_data_coverage.schema.json new file mode 100644 index 00000000..6eed3795 --- /dev/null +++ b/docroot/schema/events_data_coverage.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://api.helioviewer.org/schema/events_data_coverage.schema.json", + "title": "eventsDataCoverage request body", + "description": "Selection paths for the events timeline. Paths are canonical SOURCE>>Label[>>FRM[>>...]] strings; variable depth is allowed.", + "type": "object", + "additionalProperties": false, + "required": ["event_selections"], + "properties": { + "event_selections": { + "type": "array", + "minItems": 1, + "maxItems": 200, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 600 + } + } + } +} diff --git a/src/Actions.php b/src/Actions.php index dd30f283..b60a99b7 100644 --- a/src/Actions.php +++ b/src/Actions.php @@ -12,6 +12,7 @@ 'getUsageStatistics' => 'WebClient', 'getDataCoverageTimeline' => 'WebClient', 'getDataCoverage' => 'WebClient', + 'eventsDataCoverage' => 'WebClient', 'updateDataCoverage' => 'WebClient', // Deprecated, remove in V3, replaced by management scripts 'shortenURL' => 'WebClient', 'goto' => 'WebClient', diff --git a/src/Event/EventSelections.php b/src/Event/EventSelections.php index ddb5733d..86df9878 100644 --- a/src/Event/EventSelections.php +++ b/src/Event/EventSelections.php @@ -84,6 +84,19 @@ private function __construct(array $selections) * @param string $events_state_string Legacy event string like "[AR,all,1],[FL,NOAA_SWPC,1]" * @return EventSelections */ + /** + * Creates a new EventSelections from a pre-parsed list of selection paths. + * Used by callers (e.g. eventsDataCoverage POST body) that already speak the + * canonical SOURCE>>Label>>FRM shape and don't need the legacy-string parse. + * + * @param string[] $paths Selection paths like 'HEK>>Active Region>>SPoCA' + * @return EventSelections + */ + public static function buildFromPaths(array $paths): EventSelections + { + return new self(array_values($paths)); + } + public static function buildFromLegacyEventStrings(string $events_state_string): EventSelections { $selections = []; diff --git a/src/Event/Timeline/Timeline.php b/src/Event/Timeline/Timeline.php index aaf21339..a5217952 100644 --- a/src/Event/Timeline/Timeline.php +++ b/src/Event/Timeline/Timeline.php @@ -60,6 +60,33 @@ public function __construct( : new AggregatedCoverage()); } + /** + * Factory for callers that already have pre-parsed selection paths + * (e.g. the eventsDataCoverage POST body). Skips the legacy-string parser. + * + * @param string[] $paths Selection paths (e.g. ['HEK>>Active Region', 'WSA>>Magnetic Connectivity']) + * @param mixed $startTimestamp ms epoch + * @param mixed $endTimestamp ms epoch + * @param mixed $currentTimestamp ms epoch + * @param EventsApiInterface|null $eventsApi + * @param CoverageInterface|null $strategy + */ + public static function fromPaths( + array $paths, + $startTimestamp, + $endTimestamp, + $currentTimestamp, + ?EventsApiInterface $eventsApi = null, + ?CoverageInterface $strategy = null + ): self { + // Construct with an empty legacy string, then replace eventSelections + // with the pre-parsed paths. The empty string is a no-op inside + // buildFromLegacyEventStrings so this is safe. + $instance = new self('', $startTimestamp, $endTimestamp, $currentTimestamp, $eventsApi, $strategy); + $instance->eventSelections = EventSelections::buildFromPaths($paths); + return $instance; + } + /** * Execute the timeline query and return JSON string. * diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index 94ef3b2e..fba9e85a 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -1030,6 +1030,43 @@ public function getDataCoverage() { } } + /** + * API Endpoint: eventsDataCoverage + * + * POST replacement for the eventLayers half of getDataCoverage. Takes + * canonical selection paths in the JSON body instead of legacy event-type + * pins in the query string. + * + * Query params: currentDate, startDate, endDate (ms epoch) + * Body: { "event_selections": ["SOURCE>>Label>>FRM", ...] } + * + * Response shape unchanged from getDataCoverage's eventLayers branch -- + * see docs for eventsDataCoverage. + */ + public function eventsDataCoverage() { + try { + $json_params = $this->_params['json'] ?? []; + $paths = $json_params['event_selections'] ?? []; + + $timeline = EventTimeline::fromPaths( + $paths, + $this->_params['startDate'] ?? null, + $this->_params['endDate'] ?? null, + $this->_params['currentDate'] ?? null, + $this->eventsApi() + ); + $this->_printJSON($timeline->execute()); + } catch (InvalidArgumentException $e) { + return $this->_sendResponse(400, 'Invalid time parameters', $e->getMessage()); + } catch (Exception $e) { + // EventsApiException already captured to Sentry by EventsApi + if (!($e instanceof EventsApiException)) { + Sentry::capture($e); + } + return $this->_sendResponse(500, 'Internal server error', $e->getMessage()); + } + } + /** * Returns data coverage for IMAGE layers. * Queries the data_coverage_30_min table for image availability data. @@ -1750,6 +1787,13 @@ public function getValidationRules(): array { 'legacy_event_string' => array('eventLayers') ); break; + case 'eventsDataCoverage': + $expected = array( + 'required' => array('startDate', 'endDate', 'currentDate', 'json'), + 'ints' => array('startDate', 'endDate', 'currentDate'), + 'schema' => array('json' => 'https://api.helioviewer.org/schema/events_data_coverage.schema.json'), + ); + break; case 'getDataCoverageTimeline': $expected = array( 'optional' => array('resolution', 'endDate'), From 183abb78b6ed54eb3b11954661704fb8cb17ef3c Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 13 Jul 2026 04:22:18 +0000 Subject: [PATCH 04/31] clean getEventsBatch endpoint and LegacyEvents since there is no conversion needed anymore --- src/Event/Api/EventsApi.php | 94 +----- src/Event/Api/EventsApiInterface.php | 13 - src/Event/Api/LegacyEvents.php | 96 ------- src/Event/Api/LegacyEventsInterface.php | 27 -- tests/unit_tests/events/api/EventsApiTest.php | 38 --- .../events/api/GetEventsBatchTest.php | 161 ----------- .../GetEventsForFramesWithSelectionsTest.php | 5 +- .../events/api/LegacyEventsTest.php | 268 ------------------ 8 files changed, 5 insertions(+), 697 deletions(-) delete mode 100644 src/Event/Api/LegacyEvents.php delete mode 100644 src/Event/Api/LegacyEventsInterface.php delete mode 100644 tests/unit_tests/events/api/GetEventsBatchTest.php delete mode 100644 tests/unit_tests/events/api/LegacyEventsTest.php diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index c550c16a..283bf3b0 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -12,9 +12,9 @@ * EventsApi Client * * HTTP client for the Helioviewer Events API service. - * Handles fetching solar events (HEK, CCMC, RHESSI) with coordinate rotation, - * batch observations for movie frames, event distributions for timelines, - * and event range queries. + * Handles fetching solar events (HEK, CCMC, RHESSI, WSA) with coordinate + * rotation, batched observations with selection filtering for movie frames, + * event distributions for timelines, and event range queries. * * All methods capture errors to Sentry and throw EventsApiException on failure. */ @@ -51,18 +51,6 @@ public static function maxSelections(): int private ClientInterface $client; private SentryClientInterface $sentry; - private LegacyEventsInterface $legacyEvents; - - /** - * Filter an array of source names to only valid ones. - * - * @param string[] $sources - * @return string[] Only sources that exist in VALID_SOURCES - */ - public static function filterSources(array $sources): array - { - return array_values(array_intersect($sources, self::VALID_SOURCES)); - } /** * EventsApi constructor. @@ -71,9 +59,8 @@ public static function filterSources(array $sources): array * * @param ClientInterface|null $client Optional Guzzle client for testing * @param SentryClientInterface|null $sentry Optional Sentry client for testing - * @param LegacyEventsInterface|null $legacyEvents Optional converter for testing */ - public function __construct(ClientInterface $client = null, SentryClientInterface $sentry = null, LegacyEventsInterface $legacyEvents = null) + public function __construct(ClientInterface $client = null, SentryClientInterface $sentry = null) { $timeout = defined('HV_EVENTS_API_TIMEOUT') ? HV_EVENTS_API_TIMEOUT : 10; $connectTimeout = 2; @@ -94,7 +81,6 @@ public function __construct(ClientInterface $client = null, SentryClientInterfac $this->client = $client ?? new Client($options); $this->sentry = $sentry ?? Sentry::$client; - $this->legacyEvents = $legacyEvents ?? new LegacyEvents(); $this->sentry->setContext('EventsApi', [ 'api_url' => $baseUrl, @@ -206,78 +192,6 @@ public function getDistributions(string $size, int $fromTimestamp, int $toTimest } } - /** {@inheritdoc} */ - public function getEventsBatch(array $timestamps, array $sources, int $chunkSize = 50, string $logLabel = ''): array - { - // Only allow known sources - $validSources = self::filterSources($sources); - if (empty($validSources)) { - throw new EventsApiException("No valid sources given. Valid sources: " . implode(', ', self::VALID_SOURCES)); - } - if (empty($timestamps)) { - return []; - } - if ($chunkSize < 1) { - $chunkSize = defined('HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE') ? (int) HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE : 50; - } - $maxChunk = self::maxChunkSize(); - if ($chunkSize > $maxChunk) { - $chunkSize = $maxChunk; - } - - $sourcesParam = implode('::', $validSources); - $chunks = array_chunk($timestamps, $chunkSize); - $url = "/helioviewer/events/{$sourcesParam}/observations"; - - // Closure to fetch a single chunk of timestamps - $fetchChunk = function (array $chunkTimestamps) use ($url) { - $this->sentry->setContext('EventsApi', [ - 'endpoint' => $url, - 'timestamp_count' => count($chunkTimestamps), - ]); - - try { - $response = $this->client->request('POST', $url, [ - 'json' => ['timestamps' => $chunkTimestamps] - ]); - return $this->parseResponse($response); - } catch (\Throwable $e) { - $this->sentry->setContext('EventsApi', [ - 'error' => $e->getMessage(), - ]); - $exception = new EventsApiException("Failed to fetch batch events: " . $e->getMessage(), 0, $e); - $this->sentry->capture($exception); - throw $exception; - } - }; - - $logChunk = function (int $i, int $total, int $size, int $elapsedMs) use ($logLabel) { - if ($logLabel === '') { - return; - } - error_log(sprintf( - "[%s] EventsApi chunk %d/%d (%d timestamps) took %dms", - $logLabel, $i + 1, $total, $size, $elapsedMs - )); - }; - - // First chunk returns full response (event_types + events + observations) - $start = microtime(true); - $merged = $fetchChunk($chunks[0]); - $logChunk(0, count($chunks), count($chunks[0]), (int) round((microtime(true) - $start) * 1000)); - - // Subsequent chunks only add new observations (event_types and events are the same) - for ($i = 1; $i < count($chunks); $i++) { - $start = microtime(true); - $chunk = $fetchChunk($chunks[$i]); - $logChunk($i, count($chunks), count($chunks[$i]), (int) round((microtime(true) - $start) * 1000)); - $merged['observations'] += $chunk['observations']; - } - - // Convert deduplicated response to legacy format per timestamp - return $this->legacyEvents->convertAll($merged); - } - /** {@inheritdoc} */ public function getEventsForFramesWithSelections( array $timestamps, diff --git a/src/Event/Api/EventsApiInterface.php b/src/Event/Api/EventsApiInterface.php index b3c98f4c..e0cdbffa 100644 --- a/src/Event/Api/EventsApiInterface.php +++ b/src/Event/Api/EventsApiInterface.php @@ -50,19 +50,6 @@ public function getEventsInRange(int $fromTimestamp, int $toTimestamp, array $pa */ public function getDistributions(string $size, int $fromTimestamp, int $toTimestamp, array $paths): array; - /** - * Fetch events for multiple observation timestamps in batched requests. - * Returns legacy format keyed by timestamp. - * - * @param string[] $timestamps Array of observation datetime strings - * @param string[] $sources Array of source names (e.g. ['HEK', 'CCMC', 'RHESSI']) - * @param int $chunkSize Max timestamps per upstream POST request - * @param string $logLabel Optional label prepended to per-chunk error_log lines (e.g. "Movie:Xp66n") - * @return array Keyed by timestamp, each value is legacy-format event categories - * @throws EventsApiException on API errors or unexpected responses - */ - public function getEventsBatch(array $timestamps, array $sources, int $chunkSize = 50, string $logLabel = ''): array; - /** * Fetch events for multiple observation timestamps filtered by a list of * path-prefix selections. Posts to /helioviewer/events/frames_with_selections. diff --git a/src/Event/Api/LegacyEvents.php b/src/Event/Api/LegacyEvents.php deleted file mode 100644 index c845060b..00000000 --- a/src/Event/Api/LegacyEvents.php +++ /dev/null @@ -1,96 +0,0 @@ - $obs) { - $result[$timestamp] = $this->convert($eventTypes, $events, $obs); - } - - return $result; - } - - /** - * Convert one timestamp's batch data to legacy event_categories format. - * Merges static event data with per-timestamp rotated coordinates, - * shifts footprint polygons by the rotation delta, - * and rebuilds the category/group/data hierarchy. - * - * @param array $eventTypes Category/group structure with event_ids references - * @param array $events Static event data keyed by event ID - * @param array $obs Rotated coordinates keyed by event ID for this timestamp - * @return array Legacy format: [{pin, name, groups: [{name, data: [...]}]}] - */ - public function convert(array $eventTypes, array $events, array $obs): array - { - $categories = []; - - foreach ($eventTypes as $et) { - $groups = []; - - foreach ($et['groups'] as $group) { - $data = []; - - foreach ($group['event_ids'] as $eventId) { - if (!isset($obs[$eventId])) continue; - - $event = $events[$eventId] ?? null; - if (!$event) continue; - - $coords = $obs[$eventId]; - - $legacyEvent = $event; - $legacyEvent['id'] = $eventId; - $legacyEvent['hv_hpc_x'] = $coords['hv_hpc_x']; - $legacyEvent['hv_hpc_y'] = $coords['hv_hpc_y']; - $legacyEvent['hv_hpc_x_final'] = $coords['hv_hpc_x']; - $legacyEvent['hv_hpc_y_final'] = $coords['hv_hpc_y']; - - $dx = $coords['hv_hpc_x'] - $event['hv_hpc_x']; - $dy = $coords['hv_hpc_y'] - $event['hv_hpc_y']; - if (!empty($event['footprint'])) { - $legacyEvent['footprint'] = array_map( - fn($p) => ['x' => $p['x'] + $dx, 'y' => $p['y'] + $dy], - $event['footprint'] - ); - } - - $data[] = $legacyEvent; - } - - if (!empty($data)) { - $groups[] = ['name' => $group['name'], 'data' => $data]; - } - } - - if (!empty($groups)) { - $categories[] = [ - 'pin' => $et['pin'], - 'name' => $et['name'], - 'groups' => $groups - ]; - } - } - - return $categories; - } -} diff --git a/src/Event/Api/LegacyEventsInterface.php b/src/Event/Api/LegacyEventsInterface.php deleted file mode 100644 index 7c439f73..00000000 --- a/src/Event/Api/LegacyEventsInterface.php +++ /dev/null @@ -1,27 +0,0 @@ -mockClient, $this->mockSentry); } - - public static function filterSourcesProvider(): array - { - return [ - 'all_valid' => [ - ['HEK', 'CCMC', 'RHESSI'], - ['HEK', 'CCMC', 'RHESSI'], - ], - 'mixed_valid_and_invalid' => [ - ['HEK', 'FOO', 'BAR'], - ['HEK'], - ], - 'tree_prefixed_rejected' => [ - ['tree_HEK', 'tree_CCMC'], - [], - ], - 'empty_input' => [ - [], - [], - ], - 'all_invalid' => [ - ['FOO', 'BAR', 'BAZ'], - [], - ], - 'single_valid' => [ - ['CCMC'], - ['CCMC'], - ], - ]; - } - - /** - * @dataProvider filterSourcesProvider - */ - public function testItShouldFilterSources(array $input, array $expected): void - { - $this->assertEquals($expected, EventsApi::filterSources($input)); - } } diff --git a/tests/unit_tests/events/api/GetEventsBatchTest.php b/tests/unit_tests/events/api/GetEventsBatchTest.php deleted file mode 100644 index a871248a..00000000 --- a/tests/unit_tests/events/api/GetEventsBatchTest.php +++ /dev/null @@ -1,161 +0,0 @@ - - */ - -use PHPUnit\Framework\TestCase; -use GuzzleHttp\ClientInterface; -use GuzzleHttp\Psr7\Response; -use Helioviewer\Api\Event\Api\EventsApi; -use Helioviewer\Api\Event\Api\EventsApiException; -use Helioviewer\Api\Event\Api\LegacyEventsInterface; -use Helioviewer\Api\Sentry\ClientInterface as SentryClientInterface; - -final class GetEventsBatchTest extends TestCase -{ - private $mockClient; - private $mockSentry; - private $mockLegacyEvents; - private $eventsApi; - - protected function setUp(): void - { - $this->mockClient = $this->createMock(ClientInterface::class); - $this->mockSentry = $this->createMock(SentryClientInterface::class); - $this->mockLegacyEvents = $this->createMock(LegacyEventsInterface::class); - $this->eventsApi = new EventsApi($this->mockClient, $this->mockSentry, $this->mockLegacyEvents); - } - - public function testItShouldReturnEmptyForEmptyTimestamps(): void - { - $this->mockClient->expects($this->never())->method('request'); - $this->mockLegacyEvents->expects($this->never())->method('convertAll'); - - $result = $this->eventsApi->getEventsBatch([], ['HEK']); - $this->assertEquals([], $result); - } - - public function testItShouldThrowForInvalidSources(): void - { - $this->mockClient->expects($this->never())->method('request'); - - $this->expectException(EventsApiException::class); - $this->expectExceptionMessage('No valid sources given'); - - $this->eventsApi->getEventsBatch(['2024-01-15 12:00:00'], ['FOO', 'BAR']); - } - - public function testItShouldCallCorrectUrlWithJoinedSourcesAndTimestamps(): void - { - $batchResponse = ['event_types' => [], 'events' => [], 'observations' => []]; - - $this->mockClient->expects($this->once()) - ->method('request') - ->with('POST', '/helioviewer/events/HEK::CCMC/observations', $this->callback(function ($options) { - return $options['json']['timestamps'] === ['2024-01-15 12:00:00', '2024-01-15 12:01:00']; - })) - ->willReturn(new Response(200, [], json_encode($batchResponse))); - - $this->mockLegacyEvents->method('convertAll')->willReturn([]); - - $this->eventsApi->getEventsBatch( - ['2024-01-15 12:00:00', '2024-01-15 12:01:00'], - ['HEK', 'CCMC'] - ); - } - - public function testItShouldPaginateTimestampsAt150(): void - { - $timestamps = []; - for ($i = 0; $i < 200; $i++) { - $timestamps[] = "2024-01-15 " . sprintf('%02d:%02d:00', intdiv($i, 60), $i % 60); - } - - $batchResponse = ['event_types' => [], 'events' => [], 'observations' => []]; - - $this->mockClient->expects($this->exactly(2)) - ->method('request') - ->withConsecutive( - ['POST', '/helioviewer/events/HEK/observations', $this->callback(function ($options) { - return count($options['json']['timestamps']) === 150; - })], - ['POST', '/helioviewer/events/HEK/observations', $this->callback(function ($options) { - return count($options['json']['timestamps']) === 50; - })] - ) - ->willReturn(new Response(200, [], json_encode($batchResponse))); - - $this->mockLegacyEvents->method('convertAll')->willReturn([]); - - $this->eventsApi->getEventsBatch($timestamps, ['HEK'], 150); - } - - public function testItShouldThrowAndCaptureSentryOnHttpError(): void - { - $this->mockClient->method('request') - ->willThrowException(new \RuntimeException('connection refused')); - - $this->mockSentry->expects($this->atLeastOnce()) - ->method('setContext') - ->with('EventsApi', $this->callback(function ($params) { - return array_key_exists('error', $params) || array_key_exists('endpoint', $params) || array_key_exists('api_url', $params); - })); - - $this->mockSentry->expects($this->once()) - ->method('capture') - ->with($this->isInstanceOf(EventsApiException::class)); - - $this->expectException(EventsApiException::class); - $this->expectExceptionMessage('Failed to fetch batch events: connection refused'); - - $this->eventsApi->getEventsBatch(['2024-01-15 12:00:00'], ['HEK']); - } - - public function testItShouldMergeObservationsAcrossChunksAndPassToConverter(): void - { - $timestamps = []; - for ($i = 0; $i < 160; $i++) { - $timestamps[] = "2024-01-15 " . sprintf('%02d:%02d:00', intdiv($i, 60), $i % 60); - } - - $chunk1Response = [ - 'event_types' => [['pin' => 'AR', 'name' => 'Active Region', 'groups' => []]], - 'events' => ['evt1' => ['label' => 'AR 1']], - 'observations' => [ - '2024-01-15 00:00:00' => ['evt1' => ['hv_hpc_x' => 1.0, 'hv_hpc_y' => 2.0]], - '2024-01-15 00:01:00' => ['evt1' => ['hv_hpc_x' => 1.1, 'hv_hpc_y' => 2.1]], - ] - ]; - - $chunk2Response = [ - 'event_types' => [['pin' => 'AR', 'name' => 'Active Region', 'groups' => []]], - 'events' => ['evt1' => ['label' => 'AR 1']], - 'observations' => [ - '2024-01-15 02:30:00' => ['evt1' => ['hv_hpc_x' => 3.0, 'hv_hpc_y' => 4.0]], - ] - ]; - - $this->mockClient->expects($this->exactly(2)) - ->method('request') - ->willReturnOnConsecutiveCalls( - new Response(200, [], json_encode($chunk1Response)), - new Response(200, [], json_encode($chunk2Response)) - ); - - // Verify convertAll receives merged observations from both chunks - $this->mockLegacyEvents->expects($this->once()) - ->method('convertAll') - ->with($this->callback(function ($merged) { - $obs = $merged['observations']; - return count($obs) === 3 - && isset($obs['2024-01-15 00:00:00']) - && isset($obs['2024-01-15 00:01:00']) - && isset($obs['2024-01-15 02:30:00']) - && $obs['2024-01-15 02:30:00']['evt1']['hv_hpc_x'] == 3.0; - })) - ->willReturn([]); - - $this->eventsApi->getEventsBatch($timestamps, ['HEK'], 150); - } -} diff --git a/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php b/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php index 64d3a962..40d06972 100644 --- a/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php +++ b/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php @@ -9,22 +9,19 @@ use GuzzleHttp\Psr7\Response; use Helioviewer\Api\Event\Api\EventsApi; use Helioviewer\Api\Event\Api\EventsApiException; -use Helioviewer\Api\Event\Api\LegacyEventsInterface; use Helioviewer\Api\Sentry\ClientInterface as SentryClientInterface; final class GetEventsForFramesWithSelectionsTest extends TestCase { private $mockClient; private $mockSentry; - private $mockLegacyEvents; private $eventsApi; protected function setUp(): void { $this->mockClient = $this->createMock(ClientInterface::class); $this->mockSentry = $this->createMock(SentryClientInterface::class); - $this->mockLegacyEvents = $this->createMock(LegacyEventsInterface::class); - $this->eventsApi = new EventsApi($this->mockClient, $this->mockSentry, $this->mockLegacyEvents); + $this->eventsApi = new EventsApi($this->mockClient, $this->mockSentry); } public function testItShouldThrowForEmptySelections(): void diff --git a/tests/unit_tests/events/api/LegacyEventsTest.php b/tests/unit_tests/events/api/LegacyEventsTest.php deleted file mode 100644 index 38843136..00000000 --- a/tests/unit_tests/events/api/LegacyEventsTest.php +++ /dev/null @@ -1,268 +0,0 @@ - - */ - -use PHPUnit\Framework\TestCase; -use Helioviewer\Api\Event\Api\LegacyEvents; - -final class LegacyEventsTest extends TestCase -{ - private LegacyEvents $converter; - - protected function setUp(): void - { - $this->converter = new LegacyEvents(); - } - - public static function convertProvider(): array - { - return [ - 'single_event_single_group' => [ - // eventTypes - [ - ['pin' => 'AR', 'name' => 'Active Region', 'groups' => [ - ['name' => 'SPoCA', 'event_ids' => ['evt1']] - ]] - ], - // events - [ - 'evt1' => ['label' => 'AR 12345', 'type' => 'AR', 'hv_hpc_x' => 100.0, 'hv_hpc_y' => 200.0] - ], - // observations - [ - 'evt1' => ['hv_hpc_x' => 105.0, 'hv_hpc_y' => 210.0] - ], - // expected categories count - 1, - // expected first category pin - 'AR', - // expected first event hv_hpc_x - 105.0, - ], - 'multiple_groups' => [ - [ - ['pin' => 'AR', 'name' => 'Active Region', 'groups' => [ - ['name' => 'SPoCA', 'event_ids' => ['evt1']], - ['name' => 'SHARP', 'event_ids' => ['evt2']], - ]] - ], - [ - 'evt1' => ['label' => 'AR 1', 'type' => 'AR', 'hv_hpc_x' => 100.0, 'hv_hpc_y' => 200.0], - 'evt2' => ['label' => 'AR 2', 'type' => 'AR', 'hv_hpc_x' => 300.0, 'hv_hpc_y' => 400.0], - ], - [ - 'evt1' => ['hv_hpc_x' => 101.0, 'hv_hpc_y' => 201.0], - 'evt2' => ['hv_hpc_x' => 301.0, 'hv_hpc_y' => 401.0], - ], - 1, - 'AR', - 101.0, - ], - 'multiple_categories' => [ - [ - ['pin' => 'AR', 'name' => 'Active Region', 'groups' => [ - ['name' => 'SPoCA', 'event_ids' => ['evt1']] - ]], - ['pin' => 'FL', 'name' => 'Flare', 'groups' => [ - ['name' => 'SWPC', 'event_ids' => ['evt2']] - ]], - ], - [ - 'evt1' => ['label' => 'AR 1', 'type' => 'AR', 'hv_hpc_x' => 100.0, 'hv_hpc_y' => 200.0], - 'evt2' => ['label' => 'FL 1', 'type' => 'FL', 'hv_hpc_x' => 500.0, 'hv_hpc_y' => 600.0], - ], - [ - 'evt1' => ['hv_hpc_x' => 101.0, 'hv_hpc_y' => 201.0], - 'evt2' => ['hv_hpc_x' => 501.0, 'hv_hpc_y' => 601.0], - ], - 2, - 'AR', - 101.0, - ], - 'empty_event_types' => [ - [], - [], - [], - 0, - null, - null, - ], - 'empty_observations' => [ - [ - ['pin' => 'AR', 'name' => 'Active Region', 'groups' => [ - ['name' => 'SPoCA', 'event_ids' => ['evt1']] - ]] - ], - [ - 'evt1' => ['label' => 'AR 1', 'type' => 'AR', 'hv_hpc_x' => 100.0, 'hv_hpc_y' => 200.0], - ], - [], - 0, - null, - null, - ], - ]; - } - - /** - * @dataProvider convertProvider - */ - public function testItShouldConvertToLegacyFormat( - array $eventTypes, - array $events, - array $obs, - int $expectedCategoryCount, - ?string $expectedFirstPin, - ?float $expectedFirstHpcX - ): void { - $result = $this->converter->convert($eventTypes, $events, $obs); - - $this->assertCount($expectedCategoryCount, $result); - - if ($expectedFirstPin !== null) { - $this->assertEquals($expectedFirstPin, $result[0]['pin']); - } - - if ($expectedFirstHpcX !== null) { - $event = $result[0]['groups'][0]['data'][0]; - $this->assertEquals($expectedFirstHpcX, $event['hv_hpc_x']); - $this->assertEquals($expectedFirstHpcX, $event['hv_hpc_x_final']); - } - } - - public function testItShouldMergeRotatedCoords(): void - { - $result = $this->converter->convert( - [['pin' => 'AR', 'name' => 'Active Region', 'groups' => [ - ['name' => 'SPoCA', 'event_ids' => ['evt1']] - ]]], - ['evt1' => ['label' => 'AR 1', 'type' => 'AR', 'hv_hpc_x' => 100.0, 'hv_hpc_y' => 200.0]], - ['evt1' => ['hv_hpc_x' => 105.0, 'hv_hpc_y' => 210.0]] - ); - - $event = $result[0]['groups'][0]['data'][0]; - $this->assertEquals(105.0, $event['hv_hpc_x']); - $this->assertEquals(210.0, $event['hv_hpc_y']); - $this->assertEquals(105.0, $event['hv_hpc_x_final']); - $this->assertEquals(210.0, $event['hv_hpc_y_final']); - } - - public function testItShouldSkipInactiveEvents(): void - { - $result = $this->converter->convert( - [['pin' => 'AR', 'name' => 'Active Region', 'groups' => [ - ['name' => 'SPoCA', 'event_ids' => ['evt1', 'evt2']] - ]]], - [ - 'evt1' => ['label' => 'AR 1', 'type' => 'AR', 'hv_hpc_x' => 100.0, 'hv_hpc_y' => 200.0], - 'evt2' => ['label' => 'AR 2', 'type' => 'AR', 'hv_hpc_x' => 300.0, 'hv_hpc_y' => 400.0], - ], - // only evt1 active - ['evt1' => ['hv_hpc_x' => 101.0, 'hv_hpc_y' => 201.0]] - ); - - $data = $result[0]['groups'][0]['data']; - $this->assertCount(1, $data); - $this->assertEquals('AR 1', $data[0]['label']); - } - - public function testItShouldShiftFootprintByRotationDelta(): void - { - $result = $this->converter->convert( - [['pin' => 'AR', 'name' => 'Active Region', 'groups' => [ - ['name' => 'SPoCA', 'event_ids' => ['evt1']] - ]]], - ['evt1' => [ - 'label' => 'AR 1', 'type' => 'AR', - 'hv_hpc_x' => 100.0, 'hv_hpc_y' => 200.0, - 'footprint' => [ - ['x' => 98.0, 'y' => 198.0], - ['x' => 102.0, 'y' => 202.0], - ] - ]], - ['evt1' => ['hv_hpc_x' => 105.0, 'hv_hpc_y' => 210.0]] - ); - - $footprint = $result[0]['groups'][0]['data'][0]['footprint']; - // dx = 105 - 100 = 5, dy = 210 - 200 = 10 - $this->assertEquals(103.0, $footprint[0]['x']); - $this->assertEquals(208.0, $footprint[0]['y']); - $this->assertEquals(107.0, $footprint[1]['x']); - $this->assertEquals(212.0, $footprint[1]['y']); - } - - public function testItShouldExcludeEmptyGroups(): void - { - $result = $this->converter->convert( - [['pin' => 'AR', 'name' => 'Active Region', 'groups' => [ - ['name' => 'SPoCA', 'event_ids' => ['evt1']], - ['name' => 'SHARP', 'event_ids' => ['evt2']], - ]]], - [ - 'evt1' => ['label' => 'AR 1', 'type' => 'AR', 'hv_hpc_x' => 100.0, 'hv_hpc_y' => 200.0], - 'evt2' => ['label' => 'AR 2', 'type' => 'AR', 'hv_hpc_x' => 300.0, 'hv_hpc_y' => 400.0], - ], - // only evt1 active, evt2 not → SHARP group should be excluded - ['evt1' => ['hv_hpc_x' => 101.0, 'hv_hpc_y' => 201.0]] - ); - - $this->assertCount(1, $result[0]['groups']); - $this->assertEquals('SPoCA', $result[0]['groups'][0]['name']); - } - - public function testItShouldExcludeEmptyCategories(): void - { - $result = $this->converter->convert( - [ - ['pin' => 'AR', 'name' => 'Active Region', 'groups' => [ - ['name' => 'SPoCA', 'event_ids' => ['evt1']] - ]], - ['pin' => 'FL', 'name' => 'Flare', 'groups' => [ - ['name' => 'SWPC', 'event_ids' => ['evt2']] - ]], - ], - [ - 'evt1' => ['label' => 'AR 1', 'type' => 'AR', 'hv_hpc_x' => 100.0, 'hv_hpc_y' => 200.0], - 'evt2' => ['label' => 'FL 1', 'type' => 'FL', 'hv_hpc_x' => 500.0, 'hv_hpc_y' => 600.0], - ], - // only evt1 active → FL category excluded - ['evt1' => ['hv_hpc_x' => 101.0, 'hv_hpc_y' => 201.0]] - ); - - $this->assertCount(1, $result); - $this->assertEquals('AR', $result[0]['pin']); - } - - public function testItShouldConvertAllTimestamps(): void - { - $batchResponse = [ - 'event_types' => [ - ['pin' => 'AR', 'name' => 'Active Region', 'groups' => [ - ['name' => 'SPoCA', 'event_ids' => ['evt1']] - ]] - ], - 'events' => [ - 'evt1' => ['label' => 'AR 1', 'type' => 'AR', 'hv_hpc_x' => 100.0, 'hv_hpc_y' => 200.0], - ], - 'observations' => [ - '2024-01-15 12:00:00' => ['evt1' => ['hv_hpc_x' => 101.0, 'hv_hpc_y' => 201.0]], - '2024-01-15 12:01:00' => ['evt1' => ['hv_hpc_x' => 102.0, 'hv_hpc_y' => 202.0]], - '2024-01-15 12:02:00' => ['evt1' => ['hv_hpc_x' => 103.0, 'hv_hpc_y' => 203.0]], - ] - ]; - - $result = $this->converter->convertAll($batchResponse); - - $this->assertCount(3, $result); - $this->assertArrayHasKey('2024-01-15 12:00:00', $result); - $this->assertArrayHasKey('2024-01-15 12:01:00', $result); - $this->assertArrayHasKey('2024-01-15 12:02:00', $result); - - // Each timestamp has its own rotated coords - $this->assertEquals(101.0, $result['2024-01-15 12:00:00'][0]['groups'][0]['data'][0]['hv_hpc_x']); - $this->assertEquals(102.0, $result['2024-01-15 12:01:00'][0]['groups'][0]['data'][0]['hv_hpc_x']); - $this->assertEquals(103.0, $result['2024-01-15 12:02:00'][0]['groups'][0]['data'][0]['hv_hpc_x']); - } -} From 677210e0445f6df1074b6cd8b7e87b378bebebda Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Thu, 16 Jul 2026 04:27:52 +0000 Subject: [PATCH 05/31] add support for multisection footprint, expand old implementation to multiple arrays --- src/Event/EventContext.php | 7 ++- .../Composite/HelioviewerCompositeImage.php | 44 +++++++++++-------- tests/unit_tests/events/EventContextTest.php | 7 +-- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/src/Event/EventContext.php b/src/Event/EventContext.php index c0e3a020..fbcd5f05 100644 --- a/src/Event/EventContext.php +++ b/src/Event/EventContext.php @@ -22,7 +22,7 @@ * 'pin' => 'AR', * 'hv_hpc_x' => -119.0, // rotated for this frame * 'hv_hpc_y' => 570.2, - * 'footprint' => [{x,y}, ...], // already shifted by (dx,dy) + * 'footprint' => [[{x,y}, ...], ...], // list of rings; every point already shifted by (dx,dy) * ] * * NOTE: there is no separate label_visibility flag. The renderer treats an @@ -129,7 +129,10 @@ public static function build( $footprint = []; if (!empty($event['footprint'])) { $footprint = array_map( - fn($p) => ['x' => $p['x'] + $dx, 'y' => $p['y'] + $dy], + fn(array $ring) => array_map( + fn($p) => ['x' => $p['x'] + $dx, 'y' => $p['y'] + $dy], + $ring + ), $event['footprint'] ); } diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index 987bebe5..b53d1ab7 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -643,26 +643,13 @@ private function _addEventLayer($imagickImage) { if (empty($events_to_render)) return; // Draw event footprint polygons onto the composite image. - // Footprint is an array of {x, y} points in HPC arcseconds (already rotated by Events API). - // We convert each point from arcseconds to pixel coordinates relative to the ROI, - // then draw a semi-transparent yellow polygon matching the frontend SVG style. + // Footprint is a list of rings; each ring is a list of {x, y} points in HPC arcseconds + // (already rotated by Events API). We convert each point from arcseconds to pixel + // coordinates relative to the ROI, then draw one polygon per ring matching the + // frontend SVG style. foreach ($events_to_render as $event) { if (empty($event['footprint'])) continue; - // Convert HPC arcseconds to pixel coordinates: - // px_x = (hpc_x - roi_left) / imageScale - timeOffsetX - // px_y = (-hpc_y - roi_top) / imageScale - timeOffsetY (Y negated: HPC up → pixel down) - $polyArray = []; - foreach ($event['footprint'] as $point) { - $polyArray[] = [ - 'x' => (( $point['x'] - $this->roi->left()) / $this->roi->imageScale()) - $this->_timeOffsetX, - 'y' => ((-$point['y'] - $this->roi->top() ) / $this->roi->imageScale()) - $this->_timeOffsetY, - ]; - } - - // Need at least 3 points to form a polygon - if (count($polyArray) < 3) continue; - // Match frontend SVG spec: // - Fill: per-type color (fallback #d4d4d4) at 40% opacity (0x66) // - Stroke: black at ~53% opacity (0x88), 1.5px, round joins @@ -674,9 +661,28 @@ private function _addEventLayer($imagickImage) { $draw->setStrokeWidth(1.5); $draw->setStrokeAntialias(true); $draw->setFillColor('#' . $fillHex . '66'); - $draw->polygon($polyArray); - $imagickImage->drawImage($draw); + // Convert HPC arcseconds to pixel coordinates per ring: + // px_x = (hpc_x - roi_left) / imageScale - timeOffsetX + // px_y = (-hpc_y - roi_top) / imageScale - timeOffsetY (Y negated: HPC up → pixel down) + $anyRingDrawn = false; + foreach ($event['footprint'] as $ring) { + $polyArray = []; + foreach ($ring as $point) { + $polyArray[] = [ + 'x' => (( $point['x'] - $this->roi->left()) / $this->roi->imageScale()) - $this->_timeOffsetX, + 'y' => ((-$point['y'] - $this->roi->top() ) / $this->roi->imageScale()) - $this->_timeOffsetY, + ]; + } + // Need at least 3 points to form a polygon + if (count($polyArray) < 3) continue; + $draw->polygon($polyArray); + $anyRingDrawn = true; + } + + if ($anyRingDrawn) { + $imagickImage->drawImage($draw); + } $draw->destroy(); } diff --git a/tests/unit_tests/events/EventContextTest.php b/tests/unit_tests/events/EventContextTest.php index 0974d28f..081dd809 100644 --- a/tests/unit_tests/events/EventContextTest.php +++ b/tests/unit_tests/events/EventContextTest.php @@ -82,7 +82,7 @@ public function testHappyPathShiftsFootprintByRotationDelta(): void 'path' => 'HEK>>Active Region>>SPoCA', 'hv_hpc_x' => 100.0, 'hv_hpc_y' => 200.0, - 'footprint' => [['x' => 110.0, 'y' => 210.0]], + 'footprint' => [[['x' => 110.0, 'y' => 210.0]]], ], ], 'timestamps' => [ @@ -97,8 +97,9 @@ public function testHappyPathShiftsFootprintByRotationDelta(): void $events = $context->getEventsForDate($ts); $this->assertCount(1, $events); // dx = 120 - 100 = 20, dy = 230 - 200 = 30. Footprint point (110, 210) -> (130, 240). - $this->assertSame(130.0, $events[0]['footprint'][0]['x']); - $this->assertSame(240.0, $events[0]['footprint'][0]['y']); + // Footprint is a list of rings; single-ring, single-point fixture -> [0][0]. + $this->assertSame(130.0, $events[0]['footprint'][0][0]['x']); + $this->assertSame(240.0, $events[0]['footprint'][0][0]['y']); $this->assertSame(120.0, $events[0]['hv_hpc_x']); $this->assertSame(230.0, $events[0]['hv_hpc_y']); $this->assertTrue($context->hasEvents()); From ecb3e01dac7ed702c6cedc6077a54a8470c3bac9 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Sun, 26 Jul 2026 05:42:29 +0000 Subject: [PATCH 06/31] adopt event_selections + event_visibility_selections request shape Drop eventsState from postScreenshot/postMovie schemas in favor of the canonical event_selections + event_visibility_selections pair; add the two shared sub-schemas, reuse event_selections in eventsDataCoverage, and allow WSA in event_layers (client_state). Strip eventsState from the affected postScreenshot validation fixtures. --- docroot/schema/event_layers.schema.json | 3 +- docroot/schema/event_selections.schema.json | 14 ++++++ .../event_visibility_selections.schema.json | 24 ++++++++++ .../schema/events_data_coverage.schema.json | 14 +++--- docroot/schema/post_movie.schema.json | 7 ++- docroot/schema/post_screenshot.schema.json | 7 ++- .../valid/post_screenshot_1.json | 35 --------------- .../valid/post_screenshot_punch.json | 2 +- .../valid/post_screenshot_regression_1.json | 44 ------------------- 9 files changed, 56 insertions(+), 94 deletions(-) create mode 100644 docroot/schema/event_selections.schema.json create mode 100644 docroot/schema/event_visibility_selections.schema.json diff --git a/docroot/schema/event_layers.schema.json b/docroot/schema/event_layers.schema.json index cb7c9563..b809ac9f 100644 --- a/docroot/schema/event_layers.schema.json +++ b/docroot/schema/event_layers.schema.json @@ -16,7 +16,8 @@ "enum": [ "HEK", "CCMC", - "RHESSI" + "RHESSI", + "WSA" ] }, "visible": { "type": "boolean" }, diff --git a/docroot/schema/event_selections.schema.json b/docroot/schema/event_selections.schema.json new file mode 100644 index 00000000..942e1d56 --- /dev/null +++ b/docroot/schema/event_selections.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://api.helioviewer.org/schema/event_selections.schema.json", + "title": "Event Selections", + "description": "Selection paths for the events API. Paths are canonical SOURCE>>Label[>>FRM[>>...]] strings; variable depth is allowed. Upstream cap is 200 selections per request.", + "type": "array", + "minItems": 0, + "maxItems": 200, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 600 + } +} diff --git a/docroot/schema/event_visibility_selections.schema.json b/docroot/schema/event_visibility_selections.schema.json new file mode 100644 index 00000000..79d9d4a4 --- /dev/null +++ b/docroot/schema/event_visibility_selections.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://api.helioviewer.org/schema/event_visibility_selections.schema.json", + "title": "Event Visibility Selections", + "description": "Per-source marker and label visibility flags. Keys are canonical event source names; unknown sources are rejected. Both flags are optional per source and default to true server-side when omitted.", + "type": "object", + "additionalProperties": false, + "properties": { + "HEK": { "$ref": "#/$defs/source_flags" }, + "CCMC": { "$ref": "#/$defs/source_flags" }, + "RHESSI": { "$ref": "#/$defs/source_flags" }, + "WSA": { "$ref": "#/$defs/source_flags" } + }, + "$defs": { + "source_flags": { + "type": "object", + "additionalProperties": false, + "properties": { + "marker_visibility": { "type": "boolean" }, + "label_visibility": { "type": "boolean" } + } + } + } +} diff --git a/docroot/schema/events_data_coverage.schema.json b/docroot/schema/events_data_coverage.schema.json index 6eed3795..b6fc714f 100644 --- a/docroot/schema/events_data_coverage.schema.json +++ b/docroot/schema/events_data_coverage.schema.json @@ -2,20 +2,16 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://api.helioviewer.org/schema/events_data_coverage.schema.json", "title": "eventsDataCoverage request body", - "description": "Selection paths for the events timeline. Paths are canonical SOURCE>>Label[>>FRM[>>...]] strings; variable depth is allowed.", + "description": "Selection paths for the events timeline. Paths are canonical SOURCE>>Label[>>FRM[>>...]] strings; variable depth is allowed. At least one selection is required.", "type": "object", "additionalProperties": false, "required": ["event_selections"], "properties": { "event_selections": { - "type": "array", - "minItems": 1, - "maxItems": 200, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 600 - } + "allOf": [ + { "$ref": "https://api.helioviewer.org/schema/event_selections.schema.json" }, + { "minItems": 1 } + ] } } } diff --git a/docroot/schema/post_movie.schema.json b/docroot/schema/post_movie.schema.json index 4d676889..9700d326 100644 --- a/docroot/schema/post_movie.schema.json +++ b/docroot/schema/post_movie.schema.json @@ -19,8 +19,11 @@ "maxLength": 1000, "pattern": "^[\\_\\w\\]\\[,\\-:\\.\\+ ]+$" }, - "eventsState": { - "$ref": "https://api.helioviewer.org/schema/event_layers.schema.json" + "event_selections": { + "$ref": "https://api.helioviewer.org/schema/event_selections.schema.json" + }, + "event_visibility_selections": { + "$ref": "https://api.helioviewer.org/schema/event_visibility_selections.schema.json" }, "imageScale": { "type": "number" }, "format": { diff --git a/docroot/schema/post_screenshot.schema.json b/docroot/schema/post_screenshot.schema.json index 6092c2fa..40c3e20b 100644 --- a/docroot/schema/post_screenshot.schema.json +++ b/docroot/schema/post_screenshot.schema.json @@ -17,8 +17,11 @@ "maxLength": 1000, "pattern": "^[\\_\\w\\]\\[,\\-:\\.\\+ ]+$" }, - "eventsState": { - "$ref": "https://api.helioviewer.org/schema/event_layers.schema.json" + "event_selections": { + "$ref": "https://api.helioviewer.org/schema/event_selections.schema.json" + }, + "event_visibility_selections": { + "$ref": "https://api.helioviewer.org/schema/event_visibility_selections.schema.json" }, "scale": { "type": "boolean" }, "scaleType": { diff --git a/tests/unit_tests/validation/test_data/post_screenshot/valid/post_screenshot_1.json b/tests/unit_tests/validation/test_data/post_screenshot/valid/post_screenshot_1.json index 9eb250b8..050e2075 100644 --- a/tests/unit_tests/validation/test_data/post_screenshot/valid/post_screenshot_1.json +++ b/tests/unit_tests/validation/test_data/post_screenshot/valid/post_screenshot_1.json @@ -1,41 +1,6 @@ { "imageScale": 5.608481803348903, "layers": "[SDO,AIA,131,1,100,0,60,1,2018-03-30T04:18:36.000Z]", - "eventsState": { - "tree_HEK": { - "id": "HEK", - "layers": [ - { - "event_type": "CE", - "frms": [ - "CACTus_\\(Computer_Aided_CME_Tracking\\)" - ], - "event_instances": [], - "open": 1 - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - } - }, "scale": true, "scaleType": "earth", "scaleX": -2334, diff --git a/tests/unit_tests/validation/test_data/post_screenshot/valid/post_screenshot_punch.json b/tests/unit_tests/validation/test_data/post_screenshot/valid/post_screenshot_punch.json index 3b27a817..037624e2 100644 --- a/tests/unit_tests/validation/test_data/post_screenshot/valid/post_screenshot_punch.json +++ b/tests/unit_tests/validation/test_data/post_screenshot/valid/post_screenshot_punch.json @@ -1 +1 @@ -{"imageScale":210.11840895833043,"layers":"[PUNCH,WFI+NFI,Polarized Brightness,1,100,0,60,1,2026-04-22T14:59:57.000Z]","eventsState":{"tree_HEK":{"id":"HEK","visible":true,"markers_visible":true,"labels_visible":true,"layer_available_visible":true,"layers":[],"layers_v2":[]},"tree_CCMC":{"id":"CCMC","visible":true,"markers_visible":true,"labels_visible":true,"layer_available_visible":true,"layers":[],"layers_v2":[]},"tree_RHESSI":{"id":"RHESSI","visible":true,"markers_visible":true,"labels_visible":true,"layer_available_visible":true,"layers":[],"layers_v2":[]}},"scale":true,"scaleType":"earth","scaleX":28371,"scaleY":109542,"movieIcons":false,"date":"2026-04-22T15:59:57.497Z","display":false,"switchSources":false,"celestialBodiesLabels":"","celestialBodiesTrajectories":"","x1":-200172.4494534283,"x2":161231.21395490004,"y1":-126909.19775643574,"y2":133217.39253397734} +{"imageScale":210.11840895833043,"layers":"[PUNCH,WFI+NFI,Polarized Brightness,1,100,0,60,1,2026-04-22T14:59:57.000Z]","scale":true,"scaleType":"earth","scaleX":28371,"scaleY":109542,"movieIcons":false,"date":"2026-04-22T15:59:57.497Z","display":false,"switchSources":false,"celestialBodiesLabels":"","celestialBodiesTrajectories":"","x1":-200172.4494534283,"x2":161231.21395490004,"y1":-126909.19775643574,"y2":133217.39253397734} \ No newline at end of file diff --git a/tests/unit_tests/validation/test_data/post_screenshot/valid/post_screenshot_regression_1.json b/tests/unit_tests/validation/test_data/post_screenshot/valid/post_screenshot_regression_1.json index c16be426..050e2075 100644 --- a/tests/unit_tests/validation/test_data/post_screenshot/valid/post_screenshot_regression_1.json +++ b/tests/unit_tests/validation/test_data/post_screenshot/valid/post_screenshot_regression_1.json @@ -1,50 +1,6 @@ { "imageScale": 5.608481803348903, "layers": "[SDO,AIA,131,1,100,0,60,1,2018-03-30T04:18:36.000Z]", - "eventsState": { - "tree_HEK": { - "id": "HEK", - "layers": [ - { - "event_type": "AR", - "frms": [ - "NOAA_SWPC_Observer" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "CE", - "frms": [], - "event_instances": [ - "CE--CACTus_\\(Computer_Aided_CME_Tracking\\)--aXZvOi8vaGVsaW8taW5mb3JtYXRpY3Mub3JnL0NFX0NBQ1R1cyhDb21wdXRlckFpZGVkQ01FVHJhY2tpbmcpXzIwMjUwMzMwXzAyMTMyMl8yMDgyLjE3", - "CE--CACTus_\\(Computer_Aided_CME_Tracking\\)--aXZvOi8vaGVsaW8taW5mb3JtYXRpY3Mub3JnL0NFX0NBQ1R1cyhDb21wdXRlckFpZGVkQ01FVHJhY2tpbmcpXzIwMjUwMzI3XzIyMzEwMF84NjQwLjEy" - ], - "open": 1 - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - } - }, "scale": true, "scaleType": "earth", "scaleX": -2334, From ed2d829611773420471294c199ecadf6dbd85b3f Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Sun, 26 Jul 2026 05:45:21 +0000 Subject: [PATCH 07/31] add eventsDataCoverage into tthe statistics --- src/Database/Statistics.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Database/Statistics.php b/src/Database/Statistics.php index a105dad6..cdb435ac 100644 --- a/src/Database/Statistics.php +++ b/src/Database/Statistics.php @@ -791,6 +791,7 @@ private function _createCountsArray(){ 'getUsageStatistics' => array(), 'getDataCoverageTimeline' => array(), 'getDataCoverage' => array(), + 'eventsDataCoverage' => array(), 'updateDataCoverage' => array(), 'shortenURL' => array(), 'saveWebClientState' => array(), @@ -862,6 +863,7 @@ private function _createSummaryArray(){ 'getUsageStatistics' => 0, 'getDataCoverageTimeline' => 0, 'getDataCoverage' => 0, + 'eventsDataCoverage' => 0, 'updateDataCoverage' => 0, 'shortenURL' => 0, 'saveWebClientState' => 0, From 1a1ffd2318d41f3d4050b5843e606972209bdc6d Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 27 Jul 2026 18:42:45 +0000 Subject: [PATCH 08/31] adopt event_selections payload; lazy per-frame EventContext with dx/dy Read events per frame on demand (memory-bounded), consume the events APIs {dx,dy} directly, filter marker_visibility, and wire the new request shape through screenshot/movie endpoints. Movie worker reads new-shape rows directly; existing rows are converted by the migration scripts. --- src/Event/Api/EventsApiInterface.php | 2 +- src/Event/EventContext.php | 230 +++++++++++-------- src/Event/EventRecord.php | 35 +++ src/Module/Movies.php | 9 +- src/Module/WebClient.php | 17 +- src/Movie/HelioviewerMovie.php | 34 ++- tests/unit_tests/events/EventContextTest.php | 15 +- 7 files changed, 211 insertions(+), 131 deletions(-) create mode 100644 src/Event/EventRecord.php diff --git a/src/Event/Api/EventsApiInterface.php b/src/Event/Api/EventsApiInterface.php index e0cdbffa..f750cca0 100644 --- a/src/Event/Api/EventsApiInterface.php +++ b/src/Event/Api/EventsApiInterface.php @@ -57,7 +57,7 @@ public function getDistributions(string $size, int $fromTimestamp, int $toTimest * Returns the RAW merged response (no legacy conversion): * [ * 'events' => [ => {path, label, start, end, hv_hpc_x, hv_hpc_y, footprint, type, pin} ], - * 'timestamps' => [ => { => {hv_hpc_x, hv_hpc_y} } ], + * 'timestamps' => [ => { => {dx, dy} } ], * ] * * Timestamps are paginated by $chunkSize (capped at the upstream limit of diff --git a/src/Event/EventContext.php b/src/Event/EventContext.php index fbcd5f05..723f66f8 100644 --- a/src/Event/EventContext.php +++ b/src/Event/EventContext.php @@ -8,13 +8,20 @@ /** * Lazy aggregate of "all events the renderer needs, per date". * - * Built eagerly via build() (single HTTP call to - * EventsApi::getEventsForFramesWithSelections), then queried per-frame - * via getEventsForDate($date). The renderer never sees EventsStateManager, + * Built via build() (single HTTP call to + * EventsApi::getEventsForFramesWithSelections), then queried per-frame via + * getEventsForDate($date). The renderer never sees EventsStateManager, * never sees EventsApi, never sees the raw upstream wire shape -- it just * receives draw-ready event dicts. * - * Each event dict has the shape: + * Memory model: build() DOES NOT materialize per-frame shifted footprints. + * It keeps the raw upstream events + per-timestamp observations blocks and + * shapes exactly one frame's list on each getEventsForDate() call, then + * throws it away. That keeps live memory proportional to a single frame, + * not (frames × events × footprint_points). Callers must not accumulate the + * returned list across frames -- iterate, render, discard. + * + * Each event dict returned by getEventsForDate() has the shape: * [ * 'id' => '019c3d8f-...', * 'label' => 'AR 13700', // empty string '' when labels are hidden for this source @@ -34,14 +41,22 @@ class EventContext private static ?self $emptyInstance = null; /** - * @param array>> $eventsByDate - * date => list of event dicts. - * Invariant: every timestamp passed to build() appears as a key here, - * even if its list is empty. We rely on this to detect the - * "caller asks for an unrequested date" programming-bug case. + * @param array $requestedTimestamps Set of timestamps passed to build(), + * used to distinguish "no events at this + * frame" (expected) from "caller asked + * for a date we never fetched" (bug). + * @param array> $events Raw upstream 'events' block: uuid => event data. + * @param array> $observations + * Raw upstream 'timestamps' block: ts => uuid => {dx, dy}. + * @param array $visibilitySelections Per-source visibility flags (label_visibility only, + * marker_visibility is filtered at build() time). */ - private function __construct(private array $eventsByDate) - { + private function __construct( + private array $requestedTimestamps = [], + private array $events = [], + private array $observations = [], + private array $visibilitySelections = [] + ) { } /** @@ -51,16 +66,16 @@ private function __construct(private array $eventsByDate) */ public static function empty(): self { - return self::$emptyInstance ??= new self([]); + return self::$emptyInstance ??= new self(); } /** - * Build the context by fetching events for $timestamps filtered by $selections. + * Build the context by fetching events for $frameTimestamps filtered by $selections. * - * Empty $timestamps OR empty $selections -> no HTTP call, empty context. + * Empty $frameTimestamps OR empty $selections -> no HTTP call, empty context. * HTTP failure -> Sentry capture, returns an empty context. * - * @param string[] $timestamps dates to fetch events for + * @param string[] $frameTimestamps dates (one per movie/screenshot frame) to fetch events for * @param string[] $selections path-prefix selections (from EventsStateManager::getSelections()) * @param array $visibilitySelections per-source visibility map (from EventsStateManager::getVisibilitySelections()) * @param EventsApiInterface $api @@ -68,132 +83,147 @@ public static function empty(): self * @param string $logLabel forwarded to the API client (per-chunk error_log tag) */ public static function build( - array $timestamps, + array $frameTimestamps, array $selections, array $visibilitySelections, EventsApiInterface $api, int $chunkSize = 50, string $logLabel = '' ): self { - // Short-circuit when there's nothing to fetch. Pre-populate with empty - // lists per requested timestamp so the post-build invariant holds: - // every requested date appears as a key in $eventsByDate. - if (empty($timestamps) || empty($selections)) { - return new self(array_fill_keys($timestamps, [])); + // For each source whose marker_visibility is explicitly false, drop + // its "SOURCE>>..." selections. Missing / non-false → assume visible. + foreach ($visibilitySelections as $source => $flags) { + if (($flags['marker_visibility'] ?? true) === false) { + $selections = array_values(array_filter( + $selections, + fn(string $p) => !str_starts_with($p, $source . '>>') + )); + } + } + + $requestedTimestampsSet = array_fill_keys($frameTimestamps, true); + + // Short-circuit when there's nothing to fetch. requestedTimestamps still + // remembers the caller's set so getEventsForDate() returns [] for known + // dates without triggering the "unrequested date" Sentry signal. + if (empty($frameTimestamps) || empty($selections)) { + return new self($requestedTimestampsSet); } try { $raw = $api->getEventsForFramesWithSelections( - $timestamps, $selections, $chunkSize, $logLabel + $frameTimestamps, $selections, $chunkSize, $logLabel ); } catch (\Throwable $e) { Sentry::setContext('EventContext', [ - 'timestamps_count' => count($timestamps), - 'selections' => $selections, - 'log_label' => $logLabel, + 'frame_timestamps_count' => count($frameTimestamps), + 'selections' => $selections, + 'log_label' => $logLabel, ]); Sentry::capture($e); - return new self(array_fill_keys($timestamps, [])); + return new self($requestedTimestampsSet); } - // Shape the raw frames_with_selections response into per-date - // draw-ready arrays. Footprints get rotation-shifted by (dx,dy). - // For hidden-label sources we set 'label' to '' so the renderer - // (which already treats empty label as "skip label drawing") needs - // no extra logic. - $events = $raw['events'] ?? []; - $observations = $raw['timestamps'] ?? []; - $eventsByDate = []; - - foreach ($observations as $ts => $obs) { - $list = []; - foreach ($obs as $eventId => $coords) { - $event = $events[$eventId] ?? null; - if (!$event) { - continue; - } - - // Determine source by parsing the event's canonical path. - // path is e.g. "HEK>>Active Region>>SPoCA" - $path = $event['path'] ?? ''; - $source = explode('>>', $path)[0] ?? ''; - // Default to true: when the source isn't in the visibility map, - // assume labels are on (matches getVisibilitySelections()'s default). - $labelVisible = (bool) ($visibilitySelections[$source]['label_visibility'] ?? true); - - // Rotation delta: how much did this frame's rotated coords - // drift from the event's "canonical" coords? - $dx = $coords['hv_hpc_x'] - ($event['hv_hpc_x'] ?? 0.0); - $dy = $coords['hv_hpc_y'] - ($event['hv_hpc_y'] ?? 0.0); - - $footprint = []; - if (!empty($event['footprint'])) { - $footprint = array_map( - fn(array $ring) => array_map( - fn($p) => ['x' => $p['x'] + $dx, 'y' => $p['y'] + $dy], - $ring - ), - $event['footprint'] - ); - } - - $type = $event['type'] ?? 'UNK'; - $list[] = [ - 'id' => $eventId, - // Empty label = "do not draw a label". This is how we - // encode "labels hidden for this source" downstream. - 'label' => $labelVisible ? ($event['label'] ?? '') : '', - 'type' => $type, - 'pin' => $event['pin'] ?? $type, - 'hv_hpc_x' => $coords['hv_hpc_x'], - 'hv_hpc_y' => $coords['hv_hpc_y'], - 'footprint' => $footprint, - ]; - } - $eventsByDate[$ts] = $list; + // Wrap each raw event once so getEventsForDate can call methods on it + // instead of poking at array keys. Cheap: N events, not N × frames. + $events = []; + foreach ($raw['events'] ?? [] as $uuid => $data) { + $events[$uuid] = new EventRecord($data); } - return new self($eventsByDate); + // Stash the raw blocks verbatim. Shaping happens lazily in getEventsForDate. + return new self( + $requestedTimestampsSet, + $events, + $raw['timestamps'] ?? [], + $visibilitySelections + ); } /** * Returns the draw-ready event list for $date, or [] if none. * - * The upstream guarantees that every timestamp passed to build() ends up - * as a key in the response (even when its list is empty), so a missing - * key here when the context has some dates means the caller is asking - * for a date that was never part of the build() request. That's a - * programming bug; we Sentry-log and return [] without blowing up the - * render. + * Computes on demand: every call walks the observations for $date and + * builds fresh event dicts (with rotation-shifted footprints). Nothing is + * cached across calls, so live memory stays bounded to one frame. * - * If $this->eventsByDate is itself empty (e.g. the EventContext::empty() - * singleton, or a build() that short-circuited), the "missing key" case - * is expected — no Sentry signal. + * Invariant: every timestamp passed to build() is remembered in + * $requestedTimestamps. If the caller asks for a date that was NOT part of + * that set (and the context isn't the empty singleton), that's a + * programming bug -- we Sentry-log and return [] without blowing up render. */ public function getEventsForDate(string $date): array { - if (!array_key_exists($date, $this->eventsByDate)) { - if (!empty($this->eventsByDate)) { + if (!isset($this->requestedTimestamps[$date])) { + if (!empty($this->requestedTimestamps)) { Sentry::setContext('EventContext', [ 'requested_date' => $date, - 'available_dates' => array_keys($this->eventsByDate), - 'events_by_date' => $this->eventsByDate, + 'available_dates' => array_keys($this->requestedTimestamps), ]); Sentry::message("EventContext::getEventsForDate called with a date that wasn't part of build()"); } return []; } - return $this->eventsByDate[$date]; + + $obs = $this->observations[$date] ?? []; + if (empty($obs) || empty($this->events)) { + return []; + } + + $list = []; + foreach ($obs as $eventId => $coords) { + $event = $this->events[$eventId] ?? null; + if (!$event) { + continue; + } + + // Default to true: when the source isn't in the visibility map, + // assume labels are on. + $source = $event->getSourceFromPath(); + $labelVisible = (bool) ($this->visibilitySelections[$source]['label_visibility'] ?? true); + + // Rotation delta from canonical to this frame — now delivered + // directly by the upstream events API. + $dx = $coords['dx'] ?? 0.0; + $dy = $coords['dy'] ?? 0.0; + + $footprint = []; + $rawFootprint = $event->get('footprint'); + if (!empty($rawFootprint)) { + $footprint = array_map( + fn(array $ring) => array_map( + fn($p) => ['x' => $p['x'] + $dx, 'y' => $p['y'] + $dy], + $ring + ), + $rawFootprint + ); + } + + $type = $event->get('type', 'UNK'); + $list[] = [ + 'id' => $eventId, + 'label' => $labelVisible ? $event->get('label', '') : '', + 'type' => $type, + 'pin' => $event->get('pin', $type), + 'hv_hpc_x' => $event->get('hv_hpc_x', 0.0) + $dx, + 'hv_hpc_y' => $event->get('hv_hpc_y', 0.0) + $dy, + 'footprint' => $footprint, + ]; + } + return $list; } /** - * Whether any timestamp produced at least one event. Convenience for - * callers that want to short-circuit downstream work. + * Whether any timestamp had at least one observation referencing a known + * event. Cheap: doesn't materialize any footprints. */ public function hasEvents(): bool { - foreach ($this->eventsByDate as $list) { - if (!empty($list)) { + if (empty($this->events)) { + return false; + } + foreach ($this->observations as $obs) { + if (!empty($obs)) { return true; } } diff --git a/src/Event/EventRecord.php b/src/Event/EventRecord.php new file mode 100644 index 00000000..96674be3 --- /dev/null +++ b/src/Event/EventRecord.php @@ -0,0 +1,35 @@ +>Active Region>>SPoCA" -> "HEK". Missing / empty path -> ''. + */ + public function getSourceFromPath(): string + { + return explode('>>', $this->data['path'] ?? '', 2)[0]; + } + + /** + * Generic accessor for raw fields. Returns $default when the key is missing. + */ + public function get(string $key, $default = null) + { + return $this->data[$key] ?? $default; + } +} diff --git a/src/Module/Movies.php b/src/Module/Movies.php index 29f5cfb4..9bb0aafe 100644 --- a/src/Module/Movies.php +++ b/src/Module/Movies.php @@ -112,7 +112,12 @@ public function postMovie() { throw new Exception('Invalid layer choices! You must specify 1-5 comma-separated layer names.', 22); } - $events_manager = EventsStateManager::buildFromEventsState($json_params['eventsState']); + // Client sends event_selections + event_visibility_selections directly; + // persist that shape verbatim into the movies row for later rehydration. + $events_state_blob = json_encode([ + 'event_selections' => $json_params['event_selections'] ?? [], + 'event_visibility_selections' => $json_params['event_visibility_selections'] ?? [], + ]); // TODO 2012/04/11 // Discard any layers which do not share an overlap with the roi to @@ -169,7 +174,7 @@ public function postMovie() { $options['watermark'], $json_params['layers'], $bitmask, - $events_manager->export(), + $events_state_blob, (isset($json_params['movieIcons']) ? $json_params['movieIcons'] : false), (isset($json_params['followViewport']) ? $json_params['followViewport'] : false), $options['scale'], diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index fba9e85a..4a01253b 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -504,14 +504,12 @@ public function postScreenshot() } - $events_manager = EventsStateManager::buildFromEventsState($json_params['eventsState']); - $screenshotDate = $json_params['date']; $totalStart = microtime(true); $eventContext = EventContext::build( - timestamps: [$screenshotDate], - selections: $events_manager->getSelections(), - visibilitySelections: $events_manager->getVisibilitySelections(), + frameTimestamps: [$screenshotDate], + selections: $json_params['event_selections'] ?? [], + visibilitySelections: $json_params['event_visibility_selections'] ?? [], api: $this->eventsApi(), logLabel: "Screenshot:{$screenshotDate}", ); @@ -525,7 +523,8 @@ public function postScreenshot() Sentry::setContext('Screenshot Request Variables',[ 'layers' => $layers, - 'events_manager' => $events_manager, + 'event_selections' => $json_params['event_selections'] ?? [], + 'event_visibility_selections' => $json_params['event_visibility_selections'] ?? [], 'movieIcons' => $movieIcons, 'celestialBodies' => $celestialBodies, 'scale' => $scale, @@ -539,7 +538,7 @@ public function postScreenshot() // Create the screenshot $screenshot = new Image_Composite_HelioviewerScreenshot( $layers, - $events_manager, + EventsStateManager::buildFromEventsState([]), $movieIcons, $celestialBodies, $scale, @@ -636,7 +635,7 @@ public function takeScreenshot() { $screenshotDate = $this->_params['date']; $totalStart = microtime(true); $eventContext = EventContext::build( - timestamps: [$screenshotDate], + frameTimestamps: [$screenshotDate], selections: $events_manager->getSelections(), visibilitySelections: $events_manager->getVisibilitySelections(), api: $this->eventsApi(), @@ -760,7 +759,7 @@ public function reTakeScreenshot($screenshotId) { $screenshotDate = $metaData['observationDate']; $totalStart = microtime(true); $eventContext = EventContext::build( - timestamps: [$screenshotDate], + frameTimestamps: [$screenshotDate], selections: $events_manager->getSelections(), visibilitySelections: $events_manager->getVisibilitySelections(), api: $this->eventsApi(), diff --git a/src/Movie/HelioviewerMovie.php b/src/Movie/HelioviewerMovie.php index b8dd212b..7a47bb7f 100644 --- a/src/Movie/HelioviewerMovie.php +++ b/src/Movie/HelioviewerMovie.php @@ -88,6 +88,9 @@ class Movie_HelioviewerMovie { private $_db; private $_layers; private $_eventsManager; + private $_selections = []; + private $_visibilitySelections = []; + private $_eventsStateBlob = []; private $_roi; private $_timestamps = array(); private $_frames = array(); @@ -169,14 +172,21 @@ public function __construct($publicId, $format='mp4') { // ATTENTION! These two fields eventsLabels and eventSourceString needs to be kept in DB schema // We are keeping them to support old takeScreenshot , queueMovie requests - // Events Manager - $events_state_from_info = json_decode($info['eventsState'], true); - - if(!empty($events_state_from_info)) { - $this->_eventsManager = EventsStateManager::buildFromEventsState($events_state_from_info); - } else { - $this->_eventsManager = EventsStateManager::buildFromLegacyEventStrings($info['eventSourceString'], (bool)$info['eventsLabels']); - } + // The persisted eventsState blob is the canonical shape: + // { event_selections: [...], event_visibility_selections: {...} } + // Every pre-existing row is converted to this shape by the deploy-time + // migration scripts, so the worker reads it directly with no + // legacy-shape branch. + $events_state_from_info = json_decode($info['eventsState'], true) ?? []; + $this->_eventsStateBlob = $events_state_from_info; + $this->_selections = $events_state_from_info['event_selections'] ?? []; + $this->_visibilitySelections = $events_state_from_info['event_visibility_selections'] ?? []; + + // Plumbing only: the movie-frame composite ctor still accepts an + // EventsStateManager (shared base-class signature), but a movie frame + // never reads it -- it renders from the injected EventContext. Goes + // away when the composite ctor drops the manager parameter. + $this->_eventsManager = EventsStateManager::buildFromEventsState([]); // Regon of interest $this->_roi = Helper_RegionOfInterest::parsePolygonString($info['roi'], $info['imageScale']); @@ -337,7 +347,7 @@ public function getCompletedMovieInformation($verbose=false) { 'duration' => $this->_getDuration(), 'imageScale' => $this->imageScale, 'layers' => $this->_layers->serialize(), - 'events' => $this->_eventsManager->getState(), + 'events' => $this->_eventsStateBlob, 'x1' => $this->_roi->left(), 'y1' => $this->_roi->top(), 'x2' => $this->_roi->right(), @@ -541,9 +551,9 @@ private function _buildMovieFrames($watermark) { $contextStart = microtime(true); $eventContext = EventContext::build( - timestamps: $timestamps, - selections: $this->_eventsManager->getSelections(), - visibilitySelections: $this->_eventsManager->getVisibilitySelections(), + frameTimestamps: $timestamps, + selections: $this->_selections, + visibilitySelections: $this->_visibilitySelections, api: new EventsApi(), chunkSize: $chunkSize, logLabel: "Movie:{$movieId}", diff --git a/tests/unit_tests/events/EventContextTest.php b/tests/unit_tests/events/EventContextTest.php index 081dd809..25dfd5b0 100644 --- a/tests/unit_tests/events/EventContextTest.php +++ b/tests/unit_tests/events/EventContextTest.php @@ -87,7 +87,7 @@ public function testHappyPathShiftsFootprintByRotationDelta(): void ], 'timestamps' => [ $ts => [ - $eventId => ['hv_hpc_x' => 120.0, 'hv_hpc_y' => 230.0], + $eventId => ['dx' => 20.0, 'dy' => 30.0], ], ], ]); @@ -96,7 +96,9 @@ public function testHappyPathShiftsFootprintByRotationDelta(): void $events = $context->getEventsForDate($ts); $this->assertCount(1, $events); - // dx = 120 - 100 = 20, dy = 230 - 200 = 30. Footprint point (110, 210) -> (130, 240). + // Upstream sends dx=20, dy=30 directly. + // Footprint point (110, 210) + (dx, dy) -> (130, 240). + // Marker: canonical (100, 200) + (dx, dy) -> (120, 230). // Footprint is a list of rings; single-ring, single-point fixture -> [0][0]. $this->assertSame(130.0, $events[0]['footprint'][0][0]['x']); $this->assertSame(240.0, $events[0]['footprint'][0][0]['y']); @@ -122,7 +124,7 @@ public function testHiddenLabelIsEncodedAsEmptyString(): void ], 'timestamps' => [ $ts => [ - $eventId => ['hv_hpc_x' => 0.0, 'hv_hpc_y' => 0.0], + $eventId => ['dx' => 0.0, 'dy' => 0.0], ], ], ]); @@ -154,7 +156,7 @@ public function testVisibleLabelDefaultsToTrueWhenSourceMissingFromVisibilityMap ], ], 'timestamps' => [ - $ts => [$eventId => ['hv_hpc_x' => 0.0, 'hv_hpc_y' => 0.0]], + $ts => [$eventId => ['dx' => 0.0, 'dy' => 0.0]], ], ]); @@ -175,8 +177,7 @@ public function testGetEventsForDateOnUnknownDateLogsToSentryWhenContextNonEmpty ->method('setContext') ->with('EventContext', $this->callback(function ($params) { return array_key_exists('requested_date', $params) - && array_key_exists('available_dates', $params) - && array_key_exists('events_by_date', $params); + && array_key_exists('available_dates', $params); })); $this->mockSentry->expects($this->once())->method('message'); @@ -207,7 +208,7 @@ public function testHasEventsReturnsTrueWhenAtLeastOneDateHasEvents(): void ], ], 'timestamps' => [ - $ts1 => [$eventId => ['hv_hpc_x' => 0.0, 'hv_hpc_y' => 0.0]], + $ts1 => [$eventId => ['dx' => 0.0, 'dy' => 0.0]], $ts2 => [], ], ]); From 7a343337c696b4da0c00b5fa52c2935b0a9a28bf Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 27 Jul 2026 19:02:00 +0000 Subject: [PATCH 09/31] remove legacy events_manager bring new event_context --- src/Event/EventContext.php | 40 +++++++++++++++++-- .../Composite/HelioviewerCompositeImage.php | 7 +--- src/Image/Composite/HelioviewerMovieFrame.php | 4 +- src/Image/Composite/HelioviewerScreenshot.php | 6 +-- src/Module/WebClient.php | 38 +++++++----------- src/Movie/HelioviewerMovie.php | 12 +----- 6 files changed, 58 insertions(+), 49 deletions(-) diff --git a/src/Event/EventContext.php b/src/Event/EventContext.php index 723f66f8..63a3fefc 100644 --- a/src/Event/EventContext.php +++ b/src/Event/EventContext.php @@ -55,7 +55,9 @@ private function __construct( private array $requestedTimestamps = [], private array $events = [], private array $observations = [], - private array $visibilitySelections = [] + private array $visibilitySelections = [], + private array $rawSelections = [], + private array $rawVisibilitySelections = [] ) { } @@ -90,6 +92,12 @@ public static function build( int $chunkSize = 50, string $logLabel = '' ): self { + // Remember what the client asked for before the marker_visibility filter + // below mutates $selections. This is the blob we persist so a later + // reTakeScreenshot / movie reQueue reproduces the identical render. + $rawSelections = $selections; + $rawVisibilitySelections = $visibilitySelections; + // For each source whose marker_visibility is explicitly false, drop // its "SOURCE>>..." selections. Missing / non-false → assume visible. foreach ($visibilitySelections as $source => $flags) { @@ -107,7 +115,11 @@ public static function build( // remembers the caller's set so getEventsForDate() returns [] for known // dates without triggering the "unrequested date" Sentry signal. if (empty($frameTimestamps) || empty($selections)) { - return new self($requestedTimestampsSet); + return new self( + $requestedTimestampsSet, + rawSelections: $rawSelections, + rawVisibilitySelections: $rawVisibilitySelections + ); } try { @@ -121,7 +133,11 @@ public static function build( 'log_label' => $logLabel, ]); Sentry::capture($e); - return new self($requestedTimestampsSet); + return new self( + $requestedTimestampsSet, + rawSelections: $rawSelections, + rawVisibilitySelections: $rawVisibilitySelections + ); } // Wrap each raw event once so getEventsForDate can call methods on it @@ -136,7 +152,9 @@ public static function build( $requestedTimestampsSet, $events, $raw['timestamps'] ?? [], - $visibilitySelections + $visibilitySelections, + $rawSelections, + $rawVisibilitySelections ); } @@ -229,4 +247,18 @@ public function hasEvents(): bool } return false; } + + /** + * The persist blob: the raw event_selections + event_visibility_selections + * that produced this context, exactly as the client sent them (before the + * marker_visibility filter). Written to the screenshots/movies row so a + * cache-miss retake or reQueue rebuilds the same render. + */ + public function exportEventsStateBlob(): string + { + return json_encode([ + 'event_selections' => $this->rawSelections, + 'event_visibility_selections' => $this->rawVisibilitySelections, + ]); + } } diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index b53d1ab7..7a39d6ef 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -93,7 +93,6 @@ public static function resolveMarkerPath(string $baseDir, string $type): string protected $height; protected $interlace; protected $layers; - protected $eventsManager; protected $movieIcons; protected $scale; protected $scaleType; @@ -132,7 +131,7 @@ public static function resolveMarkerPath(string $baseDir, string $type): string * * @return void */ - public function __construct($layers, $eventsManager, $movieIcons, $celestialBodies, $scale, $scaleType, $scaleX, $scaleY, $obsDate, $roi, $options) { + public function __construct($layers, EventContext $eventContext, $movieIcons, $celestialBodies, $scale, $scaleType, $scaleX, $scaleY, $obsDate, $roi, $options) { set_time_limit(90); // Extend time limit to avoid timeouts @@ -153,7 +152,6 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi 'grayscale' => false, 'eclipse' => false, 'moon' => false, - 'eventContext' => EventContext::empty(), ); $options = array_replace($defaults, $options); @@ -163,9 +161,8 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi $this->imageScale = $roi->imageScale(); $this->db = $options['database'] ? $options['database'] : new Database_ImgIndex(); - $this->eventContext = $options['eventContext']; + $this->eventContext = $eventContext; $this->layers = $layers; - $this->eventsManager = $eventsManager; $this->movieIcons = $movieIcons; $this->scale = $scale; $this->scaleType = $scaleType; diff --git a/src/Image/Composite/HelioviewerMovieFrame.php b/src/Image/Composite/HelioviewerMovieFrame.php index 3a680565..510bc9f8 100644 --- a/src/Image/Composite/HelioviewerMovieFrame.php +++ b/src/Image/Composite/HelioviewerMovieFrame.php @@ -18,9 +18,9 @@ class Image_Composite_HelioviewerMovieFrame extends Image_Composite_HelioviewerC /** * Helioviewer movie frame */ - public function __construct($filepath, $layers, $eventsManager, $movieIcons, $celestialBodies, $scale, $scaleType, $scaleX, $scaleY, $obsDate, $roi, $options) + public function __construct($filepath, $layers, $eventContext, $movieIcons, $celestialBodies, $scale, $scaleType, $scaleX, $scaleY, $obsDate, $roi, $options) { - parent::__construct($layers, $eventsManager, $movieIcons, $celestialBodies, $scale, $scaleType, $scaleX, $scaleY, $obsDate, $roi, $options); + parent::__construct($layers, $eventContext, $movieIcons, $celestialBodies, $scale, $scaleType, $scaleX, $scaleY, $obsDate, $roi, $options); $this->build($filepath); } diff --git a/src/Image/Composite/HelioviewerScreenshot.php b/src/Image/Composite/HelioviewerScreenshot.php index f26019c6..65fb88fb 100644 --- a/src/Image/Composite/HelioviewerScreenshot.php +++ b/src/Image/Composite/HelioviewerScreenshot.php @@ -22,9 +22,9 @@ class Image_Composite_HelioviewerScreenshot extends Image_Composite_HelioviewerC /** * Creates a new screenshot */ - public function __construct($layers, $eventsManager, $movieIcons, $celestialBodies, $scale, $scaleType, $scaleX, $scaleY, $obsDate, $roi, $options) { + public function __construct($layers, $eventContext, $movieIcons, $celestialBodies, $scale, $scaleType, $scaleX, $scaleY, $obsDate, $roi, $options) { - parent::__construct($layers, $eventsManager, $movieIcons, $celestialBodies, $scale, $scaleType, $scaleX, $scaleY, $obsDate, $roi, $options); + parent::__construct($layers, $eventContext, $movieIcons, $celestialBodies, $scale, $scaleType, $scaleX, $scaleY, $obsDate, $roi, $options); if ( array_key_exists('action', $options) && $options['action'] == 'downloadScreenshot' ) { @@ -76,7 +76,7 @@ private function _getScreenshotId() $this->watermark, $this->layers->serialize(), $this->layers->getBitMask(), - $this->eventsManager->export(), + $this->eventContext->exportEventsStateBlob(), $this->movieIcons, $this->scale, $this->scaleType, diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index 4a01253b..503c1d9a 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -538,7 +538,7 @@ public function postScreenshot() // Create the screenshot $screenshot = new Image_Composite_HelioviewerScreenshot( $layers, - EventsStateManager::buildFromEventsState([]), + $eventContext, $movieIcons, $celestialBodies, $scale, @@ -547,7 +547,7 @@ public function postScreenshot() $scaleY, $json_params['date'], $roi, - array_merge($json_params, ['eventContext' => $eventContext]) + $json_params ); // Display screenshot @@ -652,7 +652,7 @@ public function takeScreenshot() { // Create the screenshot $screenshot = new Image_Composite_HelioviewerScreenshot( $layers, - $events_manager, + $eventContext, $movieIcons, $celestialBodies, $scale, @@ -661,7 +661,7 @@ public function takeScreenshot() { $scaleY, $this->_params['date'], $roi, - array_merge($this->_options, ['eventContext' => $eventContext]) + $this->_options ); // Display screenshot @@ -743,25 +743,18 @@ public function reTakeScreenshot($screenshotId) { 'layer names.', 22); } - // Event Layers - $events_state_from_metadata = json_decode($metaData['eventsState'], true); - $events_manager; - - // ATTENTION! These two fields eventsLabels and eventSourceString needs to be kept in DB schema - // We are keeping them to support old takeScreenshot , queueMovie requests - - if(!empty($events_state_from_metadata)) { - $events_manager = EventsStateManager::buildFromEventsState($events_state_from_metadata); - } else { - $events_manager = EventsStateManager::buildFromLegacyEventStrings($metaData['eventSourceString'], (bool)$metaData['eventsLabels']); - } + // Event Layers — the persisted blob is the canonical shape; every row + // is converted by the deploy-time migration scripts, so read it directly. + $events_state_from_metadata = json_decode($metaData['eventsState'], true) ?? []; + $selections = $events_state_from_metadata['event_selections'] ?? []; + $visibilitySelections = $events_state_from_metadata['event_visibility_selections'] ?? []; $screenshotDate = $metaData['observationDate']; $totalStart = microtime(true); $eventContext = EventContext::build( frameTimestamps: [$screenshotDate], - selections: $events_manager->getSelections(), - visibilitySelections: $events_manager->getVisibilitySelections(), + selections: $selections, + visibilitySelections: $visibilitySelections, api: $this->eventsApi(), logLabel: "Screenshot:{$screenshotDate}", ); @@ -780,7 +773,7 @@ public function reTakeScreenshot($screenshotId) { // Create the screenshot $screenshot = new Image_Composite_HelioviewerScreenshot( $layers, - $events_manager, + $eventContext, (bool)$metaData['movieIcons'], $celestialBodies, (bool)$metaData['scale'], @@ -789,7 +782,7 @@ public function reTakeScreenshot($screenshotId) { $metaData['scaleY'], $metaData['observationDate'], $roi, - array_merge($options, ['eventContext' => $eventContext]) + $options ); } @@ -1416,9 +1409,6 @@ public function getEclipseImage() { // ATTENTION! These two fields eventsLabels and eventSourceString needs to be kept in DB schema // We are keeping them to support old takeScreenshot , queueMovie requests - // Create empty events object required for screenshots. - $events_manager = EventsStateManager::buildFromLegacyEventStrings('', false); - // Create empty celestial bodies list $celestialBodies = array( "labels" => "", "trajectories" => ""); @@ -1427,7 +1417,7 @@ public function getEclipseImage() { include_once HV_ROOT_DIR.'/../src/Image/Composite/HelioviewerScreenshot.php'; $screenshot = new Image_Composite_HelioviewerScreenshot( $layers, - $events_manager, + EventContext::empty(), false, $celestialBodies, false, diff --git a/src/Movie/HelioviewerMovie.php b/src/Movie/HelioviewerMovie.php index 7a47bb7f..f7ab120b 100644 --- a/src/Movie/HelioviewerMovie.php +++ b/src/Movie/HelioviewerMovie.php @@ -35,7 +35,6 @@ require_once HV_ROOT_DIR . '/../src/Helper/RegionOfInterest.php'; require_once HV_ROOT_DIR . '/../src/Helper/Serialize.php'; -use Helioviewer\Api\Event\EventsStateManager; use Helioviewer\Api\Event\EventContext; use Helioviewer\Api\Event\Api\EventsApi; use Helioviewer\Api\Sentry\Sentry; @@ -87,7 +86,6 @@ class Movie_HelioviewerMovie { private $_db; private $_layers; - private $_eventsManager; private $_selections = []; private $_visibilitySelections = []; private $_eventsStateBlob = []; @@ -182,12 +180,6 @@ public function __construct($publicId, $format='mp4') { $this->_selections = $events_state_from_info['event_selections'] ?? []; $this->_visibilitySelections = $events_state_from_info['event_visibility_selections'] ?? []; - // Plumbing only: the movie-frame composite ctor still accepts an - // EventsStateManager (shared base-class signature), but a movie frame - // never reads it -- it renders from the injected EventContext. Goes - // away when the composite ctor drops the manager parameter. - $this->_eventsManager = EventsStateManager::buildFromEventsState([]); - // Regon of interest $this->_roi = Helper_RegionOfInterest::parsePolygonString($info['roi'], $info['imageScale']); } @@ -568,8 +560,6 @@ private function _buildMovieFrames($watermark) { $contextMs )); - $options['eventContext'] = $eventContext; - // Index of preview frame $previewIndex = floor($this->numFrames/2); @@ -589,7 +579,7 @@ private function _buildMovieFrames($watermark) { try { $frameStart = microtime(true); $screenshot = new Image_Composite_HelioviewerMovieFrame( - $filepath, $this->_layers, $this->_eventsManager, + $filepath, $this->_layers, $eventContext, $this->movieIcons, $this->celestialBodies, $this->scale, $this->scaleType, $this->scaleX, $this->scaleY, $time, $this->_roi, $options); From 33dc188697bc4e674575bc848f2abeee0f7d64ed Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 27 Jul 2026 19:04:28 +0000 Subject: [PATCH 10/31] add MC (Magnetic Connectivity) event marker pin WSA Magnetic Connectivity events fell back to UNK.png for lack of an MC marker. Add MC.png + MC@2x.png --- docroot/resources/images/eventMarkers/MC.png | Bin 0 -> 1323 bytes docroot/resources/images/eventMarkers/MC@2x.png | Bin 0 -> 2861 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docroot/resources/images/eventMarkers/MC.png create mode 100644 docroot/resources/images/eventMarkers/MC@2x.png diff --git a/docroot/resources/images/eventMarkers/MC.png b/docroot/resources/images/eventMarkers/MC.png new file mode 100644 index 0000000000000000000000000000000000000000..50624e55fbb49d44c2402c572a14dce857650c73 GIT binary patch literal 1323 zcmV+`1=RY9P)jvF$iPom|j~b0Ou5}}+ zsML)DB1)sQ(kduYR6y%eD@|MxwOT-UyoKeS{^5fHvOFL?$z+l_^ZVw^+?jKa5)qm+ zrPN@+2N(uuKoxKW$Pjm8|UBHeem_jfhz4vFgH=Qf@$sx3_!f zj!og7rhebkl9lJbOVqMt%abSNe*hsOQq>gvY+t{wft&U}G0{rwgB6D z_;j>?y*W()F&a&hnKVmwe>+nW<3Ew!euD&%d5gBnkxY@q{Sh*9{M+K;=_N*^N&5PB zk%h}9N%9$8`ucUX0qaEsO(|sn-upCSWNp+&-Nv!}aJ+-!DAE>F^!PqTlPdr{&iE7c z#YyfLq_cW^278Y`VcLh=*dCqEmAq3#&JA?~<}0NP8t^ipCX5|WyS<@d)47oOBLKNE zuQ4bfjLM2?05bC{DZH1@?0LKK>lc8@ksW}6!!zogcPB?*pf=#@YN!H+Xh7#)y}VrOP8>3P zG}dyRD;LwbnwQS_$TXCCcCj?g1pvGGvnD^RPxp>4Ku-;*EHhiH>pESHCWZ!%A%4$F zyt}=KSKjPepMTG1@H=;xHVHSE+pB;A4ahGowO2Qs5Ewj>YnL(z@HOK(EeAmdVASA_ z`1ubbW_>QV{<=g(g^ir7WBj}(hI40*0#H(7)qx@xAO|qyr%wgXXIt%YYYssnu>ge3 zh$ZpV0+i#uY08aNThrLHZ3!!)#!+Fj(7mT0FNF=`)z>GJoje|(0yw8c1f|qk-`-uK zw*F9LqMa5Q-r4ftVd*LnSyS`;qwf_I>;GJy*)H(k7vJK+!%_vr)=OMUsV&{QdCb|F zY<6!e{DUd(mf~XTP7zsHFL43OA3rWPCu|976F6>DAjQSj5@30~#O25_7GT!FgglE@ zIJZ?`*GKb-Pr7IUri+N}Ik;niUx0+jg-O=dz&~Dm$POG5k?gwTS|f8K?iZGnq)f7;fJwt^gJ_^l6R!uyEEh9@lkU?f|^SUlKQ}={6 z>OZzC1c66Qft!UpY<2AZ3uTQt_QmCv0c)ERw**hL+p8*WoSIT^@al<4*ld*=aInpA zNBwc&aLVPXdap4tw-SgJ5o>!Q2iSN1!d+Y4fos1%kOUuI>)(=zR4FExwQO!JvT zqJmPXXisF0)D%NONkkG%hMOod$Z+@j*yo(R&i(V4bv8mu z$=$fwHueHS2z#J6&ys8>rU+%7HJH z8z{CKxDcWXupj8D4%WB#y{uR|mg4ps8f2n|KW6jUx+tPzQcVvh0h6SZ=NkVJYQ}x`KxQ69D$dG|$XBexOjB-$*Iv zR6eh>06j&4mc4p*Y_=)=;fK6ccR5=KF}g;L3nAQq3qS{FX9u>26*n7rXWb(D4_-rA znE@aZ@G~`;${L@i+Vxwn4QtjQD&6oYOBM&HyB&dlTGY4@!Unhmbn^CcW&Pe8Eg}kh z;h9#dkIw-*ODT&gM~CBqP5=vD9t^;@>lYKUW2M%?!>co|j&Z?%c4CzQRxR&GbXbMw z^6KcrhMv^o8FI&V)TTUack$P^`HCq$N6;a zF#r!>BCw;fsmunjvC+|E*bf%(KId9GtsdWO@myMRfyLuP(vhbg%Kz z#YOC`a_o<^b7Z7f6JFRE|E&9D9!I|42!OL|JLWA6Wb?iZc83=cw>^MS&(22(K~Z5o z@6OxKg>&)RB}R?tp?a7AA%vr;#(SwVG@=#s;37^8o91SrSO&Yrl09Ow=< zHnx1e?W{%DsMD>+V2}WQrW$Vx;N|I3t!=k$=S7zXdTHiTVe=L30po*^(%ww&cBz5u ze_hk|*G&i{t!x!p_N&OU|BMXr3Aeicul5P4Q(LupPZe-)FW2gGQzVYQtpT`tC56kW zzZtc!fOqxnji+~)DslydWoG;K_Kpqm=N&vWrFxr!y8$>k+SMrBrc(u2eB?g#*%|zv zeB5Ze!=_r4acON{NVu-Q)*x^bmuNQSmjh(wt-#s2M*Gq`Ym} z8`|p3ordr!yoc##-zbb2NybBZ7%J8pM|?U*vXip>{4m|F(AT zrr;6k(ahtMYlQH=w;Nr3dz;_P%G4TV?(gQ$q?txjG8hbeu<&U@cda5P>krDyO3BPB zWcyd|u;#;=6*0V{`^5rkGytS5rS8q1d_Zz~Lk@?@%JIMYq@zxQH!*i%fbLQ$q*GR?JplFxDucr0YunOmSYb2#AbZ+o-+nMCLTm>?>S`DmNeK^mwYpA))L;| z`5F^vyhXPj{cvgHj*X2iuC49x>E4&g&%eXEofjB6ZmyYZRBWm`EBMUOj;b5fD`{JS#bjAZ znGY-m$jd8|+gJ5!Ht5i=9y9gb|1yQPSP&A%nIclnh(dPGLf7e=I|}Wb_c9}&S+Ca9 z6M(*zzhG2X$$C%`u=DbC5gUSU-cj&Zr?n*`GfxE@B&Ce4wUVWnOfv8>Ag*QQ6SA)V z9fRH**pH0NJk@Wnu>@UPwbnt=dJll0Z=c7Z>((ZPE=_xZDKoV(eghs=yj{KPa$Ni?;Wne(hOdVPT!y7rS&o~t%KujxWk%HzPl0g;zi#OLq&HW~EB4Tqq z$2*gotC>hIDdmM)Q@OfaSW1})ECtBUxy6yK1FZ%fxoe0D4X~sx&~?_hqIG-VJkUj_ z6NE$=tO7oHh>cnUPf=>%WpzrP>pFmxvIKb7n8iqTuI}Av=uIp9C@V9lntQXZ(DedW zwq0uKv?nBiF1Bwp7`nsl1^jp@Nqr%sq?DocDp%B}vozY(n@S!fm##S6VDJ^j-v=#e*NTj1-VZEU{>lSe!Ow)F#l=4KAgR5`9dLJv-gf~Xp04o~; zAR4+tNC*)FJZWR2BRI0WUa}Jg>C_5Xl$7$R29@L*;S|;y3quCt_l>BtP{emnVWO;+ z4IR8O8n?yxmF_-w_ttCsxB{dyr7mpb*74FqVg#uc9%2%y&&kb7o&tulG% z{-#0$d?g#PlC3eO0`N6Z2*^!a<7@4_ep8%jMQVFv25uC%l=3>T2N3bc(`)Vj%ZYT= z1qMqg^O_A@*}l_Kmax&)c1k#CR;St2*g>rUeiY-19(L@mzTCWL-x;-;s#Fo07hHKs z_X1o<$>v`9y2XCylC@7Ld!!YXms&&PX6B-lgw3`}CKO_g0j&idjq*80Nx{rZ4*P#o zn^zhVmoyo;^62*foK5<(s>aV=Rh#>)l{r~U<6vBB3P9GWio*ZpxCy3xS|oThPz1<> zM=H!EI_{D>=@cn7nwABwtci^V#LcIf#n~+II@XfGPXeWYZB*=2|)kIm{i)h zI;!@aRG-pAbJ#da(Fnc1fJ Date: Mon, 27 Jul 2026 19:08:54 +0000 Subject: [PATCH 11/31] add Makefile with dev/test shortcuts and documented it in the README --- Makefile | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 19 ++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..3b2393c9 --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +DOCKER_EXEC = docker compose exec api +PHPUNIT = $(DOCKER_EXEC) vendor/bin/phpunit --bootstrap tests/autoload.php --testdox + +.PHONY: help test event-tests regression-tests test-filter test-file shell exec restart-movies db-shell + +.DEFAULT_GOAL := help + +help: + @echo "Available commands:" + @echo " make test - Run all unit tests" + @echo " make event-tests - Run EventsApi tests" + @echo " make regression-tests - Run regression tests" + @echo " make test-filter f=... - Run tests whose name matches the PHPUnit --filter regex" + @echo " e.g. make test-filter f=testItShouldFallBack" + @echo " e.g. make test-filter f=LegacyEventString" + @echo " make test-file p=... - Run a single test file or directory under tests/unit_tests" + @echo " e.g. make test-file p=tests/unit_tests/validation/LegacyEventStringTest.php" + @echo " e.g. make test-file p=tests/unit_tests/events" + @echo " make shell - Open bash shell in api container" + @echo " make exec cmd=\"...\" - Run a command in api container" + @echo " make restart-movies - Restart movies container" + @echo " make db-shell - Open MySQL shell" + +shell: + $(DOCKER_EXEC) bash + +exec: + $(DOCKER_EXEC) $(cmd) + +test: + $(PHPUNIT) tests/unit_tests + +event-tests: + $(PHPUNIT) tests/unit_tests/events/ + +regression-tests: + $(PHPUNIT) tests/unit_tests/regression/ + +# Run only tests whose method name matches the given regex. +# Usage: make test-filter f= (also accepts: filter=, FILTER=) +test-filter: + @if [ -z "$(f)$(filter)$(FILTER)" ]; then \ + echo "Error: pass a filter, e.g. make test-filter f=testItShouldFallBack"; exit 1; \ + fi + $(PHPUNIT) tests/unit_tests --filter '$(f)$(filter)$(FILTER)' + +# Run a single test file (or limit to a sub-directory). +# Usage: make test-file p=tests/unit_tests/validation/LegacyEventStringTest.php +test-file: + @if [ -z "$(p)$(path)$(PATH_)" ]; then \ + echo "Error: pass a path, e.g. make test-file p=tests/unit_tests/validation/LegacyEventStringTest.php"; exit 1; \ + fi + $(PHPUNIT) '$(p)$(path)$(PATH_)' + +restart-movies: + docker compose restart movies + +db-shell: + docker compose exec database mariadb -u helioviewer -phelioviewer helioviewer diff --git a/README.md b/README.md index 6537a51f..df924f1f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,25 @@ If you wish to run your own local copy of Helioviewer.org, you have 2 options. - Through the development [container](https://hub.docker.com/r/dgarciabriseno/helioviewer.org-docker). - Manual Installation with instructions [here](https://helioviewer-project.github.io/install/). +## Development + +A `Makefile` wraps the common `docker compose` workflows for the API container. +Run `make help` for the full list. The most-used targets: + +| Command | What it does | +| --- | --- | +| `make test` | Run all unit tests | +| `make event-tests` | Run the events test suite | +| `make regression-tests` | Run the regression suite | +| `make test-file p=` | Run a single test file or directory, e.g. `make test-file p=tests/unit_tests/events` | +| `make test-filter f=` | Run tests whose method name matches a regex, e.g. `make test-filter f=testItShouldFallBack` | +| `make shell` | Open a bash shell in the `api` container | +| `make exec cmd="..."` | Run an arbitrary command in the `api` container | +| `make restart-movies` | Restart the `movies` worker container | +| `make db-shell` | Open a MySQL shell against the dev database | + +All targets assume the development container is running (`docker compose up`). + ## License Helioviewer.org is licensed under the Mozilla Public License. From c2132a2b6a98d214ee169010415f1d64dfab4f15 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 27 Jul 2026 22:01:52 +0000 Subject: [PATCH 12/31] craete intermediate files for pin url parsing and use this new classes --- src/Event/EventTree.php | 8 +- src/Event/EventTypeCatalogue.php | 70 ++++++++++ src/Event/LegacyEventsStringParser.php | 123 +++++++++++++++++ tests/unit_tests/events/EventTreeTest.php | 2 +- .../events/LegacyEventsStringParserTest.php | 124 ++++++++++++++++++ 5 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 src/Event/EventTypeCatalogue.php create mode 100644 src/Event/LegacyEventsStringParser.php create mode 100644 tests/unit_tests/events/LegacyEventsStringParserTest.php diff --git a/src/Event/EventTree.php b/src/Event/EventTree.php index f1458409..fdefdf71 100644 --- a/src/Event/EventTree.php +++ b/src/Event/EventTree.php @@ -8,7 +8,7 @@ /** * Buckets a flat list of events (the shape returned by * EventsApi::getEventsForSource()) into a "SOURCE>>Label" tree, using - * EventSelections::$event_types_map as the canonical catalogue. + * EventTypeCatalogue::MAP as the canonical catalogue. * * Usage: * $tree = EventTree::make($flatEvents, $requestedSources); @@ -46,10 +46,10 @@ public static function make(array $events, array $sources, ?SentryClientInterfac // Pre-seed every known bucket for the requested sources. foreach ($sources as $source) { - if (!isset(EventSelections::$event_types_map[$source])) { + if (!isset(EventTypeCatalogue::MAP[$source])) { continue; } - foreach (EventSelections::$event_types_map[$source] as $code => $label) { + foreach (EventTypeCatalogue::MAP[$source] as $code => $label) { $buckets[$source . '>>' . $label] = []; } } @@ -93,7 +93,7 @@ public static function make(array $events, array $sources, ?SentryClientInterfac 'requested_sources' => $sources, ]); $sentry->message( - 'simpletree: encountered paths not in EventSelections::$event_types_map' + 'simpletree: encountered paths not in EventTypeCatalogue::MAP' ); } diff --git a/src/Event/EventTypeCatalogue.php b/src/Event/EventTypeCatalogue.php new file mode 100644 index 00000000..4fb83a38 --- /dev/null +++ b/src/Event/EventTypeCatalogue.php @@ -0,0 +1,70 @@ + { event_type_code => human label } map for every + * event source Helioviewer understands. Single source of truth for turning a + * 2-letter event-type code into a canonical "SOURCE>>Label" path segment. + * + * Lifted verbatim from the former EventSelections::$event_types_map so the map + * survives that class's retirement. Consumed by EventTree (simpletree output) + * and LegacyEventsStringParser (URL bracket-string parsing). + * + * @category Event + * @package Helioviewer + * @license http://www.mozilla.org/MPL/MPL-1.1.html Mozilla Public License 1.1 + * @link https://github.com/Helioviewer-Project + */ + +namespace Helioviewer\Api\Event; + +final class EventTypeCatalogue +{ + public const MAP = [ + 'HEK' => [ + 'AR' => 'Active Region', + 'CE' => 'CME', + 'CH' => 'Coronal Hole', + 'EF' => 'Emerging Flux', + 'FI' => 'Filament', + 'FL' => 'Flare', + 'SG' => 'Sigmoid', + 'CC' => 'Coronal Cavity', + 'CD' => 'Coronal Dimming', + 'CJ' => 'Coronal Jet', + 'CR' => 'Coronal Rain', + 'CW' => 'Coronal Wave', + 'ER' => 'Eruption', + 'FA' => 'Filament Activation', + 'FE' => 'Filament Eruption', + 'LP' => 'Loop', + 'OS' => 'Oscillation', + 'PG' => 'Plage', + 'SP' => 'Spray Surge', + 'SS' => 'Sunspot', + 'OT' => 'Other', + 'NR' => 'Nothing Reported', + 'TO' => 'Topological Object', + 'HY' => 'Hypothesis', + 'BU' => 'UVBurst', + 'EE' => 'Explosive Event', + 'PB' => 'Prominence Bubble', + 'PT' => 'Peacock Tail', + 'EP' => 'SEPs', + 'IC' => 'ICMEs', + 'SR' => 'SIRs', + // 'UNK' => 'Unknown', // Not in events API + ], + 'CCMC' => [ + 'C3' => 'DONKI', + 'FP' => 'Solar Flare Predictions', + ], + 'RHESSI' => [ + 'F2' => 'Solar Flares', + ], + 'WSA' => [ + 'MC' => 'Magnetic Connectivity', + 'CH' => 'Coronal Hole', + ], + ]; +} diff --git a/src/Event/LegacyEventsStringParser.php b/src/Event/LegacyEventsStringParser.php new file mode 100644 index 00000000..25b5a21c --- /dev/null +++ b/src/Event/LegacyEventsStringParser.php @@ -0,0 +1,123 @@ +>Label[>>FRM]" selection paths, resolving each + * event-type code against EventTypeCatalogue::MAP. + * + * Parsing logic lifted verbatim from the former + * EventSelections::buildFromLegacyEventStrings so it survives that class's + * retirement. The per-triple visibility flag (the trailing 1/0) is ignored, + * matching the original behavior -- every listed event is treated as selected. + * + * @category Event + * @package Helioviewer + * @license http://www.mozilla.org/MPL/MPL-1.1.html Mozilla Public License 1.1 + * @link https://github.com/Helioviewer-Project + */ + +namespace Helioviewer\Api\Event; + +final class LegacyEventsStringParser +{ + /** + * Parse a legacy bracket-string into canonical selection paths. + * + * @param string $events_state_string e.g. "[AR,all,1],[FL,NOAA_SWPC,1]" + * @return string[] canonical "SOURCE>>Label[>>FRM]" paths + */ + public static function parse(string $events_state_string): array + { + $selections = []; + + // Prevent possible bugs + $events_state_string = trim($events_state_string); + + if (!empty($events_state_string)) { + $stripped = stripslashes($events_state_string); + // Remove only the outermost [ and ] + if (str_starts_with($stripped, '[') && str_ends_with($stripped, ']')) { + $stripped = substr($stripped, 1, -1); + } + $event_strings = explode("],[", $stripped); + + // Process individual events in string + foreach ($event_strings as $es) { + + $event_pieces = explode(",", $es); + + // there should be 3 elements + if (count($event_pieces) < 3) { + continue; + } + + list($event_type, $combined_frms, $visible) = $event_pieces; + + // Find the source (HEK, CCMC, RHESSI, WSA) and label for this event_type + $source = null; + $label = null; + foreach (EventTypeCatalogue::MAP as $src => $types) { + if (array_key_exists($event_type, $types)) { + $source = $src; + $label = $types[$event_type]; + break; + } + } + + // Skip if event_type not found in map + if ($source === null || $label === null) { + continue; + } + + $frms = explode(";", $combined_frms); + + // If 'all' or empty frms, just use SOURCE>>LABEL + if (empty($combined_frms) || $combined_frms === 'all' || in_array('all', $frms)) { + $selections[] = $source . '>>' . $label; + } else { + // For each specific FRM, create SOURCE>>LABEL>>FRM + foreach ($frms as $frm) { + $frm = trim($frm); + if (!empty($frm)) { + $selections[] = $source . '>>' . $label . '>>' . $frm; + } + } + } + } + } + + return $selections; + } + + /** + * Convenience for the URL-params screenshot/movie endpoints: parse the + * ?events= string into selection paths and pair it with a per-source + * visibility map derived from the global ?eventLabels= flag. + * + * The legacy URL format has no per-source marker toggle, so markers are + * uniformly visible; label_visibility comes from $labels. + * + * @param string $events_state_string the ?events= value + * @param bool $labels the ?eventLabels= value + * @return array{0: string[], 1: array} + */ + public static function selectionsFromUrlParams(string $events_state_string, bool $labels): array + { + $selections = self::parse($events_state_string); + + $visibility = []; + foreach (array_keys(EventTypeCatalogue::MAP) as $source) { + $visibility[$source] = [ + 'marker_visibility' => true, + 'label_visibility' => $labels, + ]; + } + + return [$selections, $visibility]; + } +} diff --git a/tests/unit_tests/events/EventTreeTest.php b/tests/unit_tests/events/EventTreeTest.php index 0887879f..c191e293 100644 --- a/tests/unit_tests/events/EventTreeTest.php +++ b/tests/unit_tests/events/EventTreeTest.php @@ -93,7 +93,7 @@ public function testItCreatesDynamicBucketAndReportsUnknownPath(): void $this->sentry->expects($this->once()) ->method('message') ->with($this->equalTo( - 'simpletree: encountered paths not in EventSelections::$event_types_map' + 'simpletree: encountered paths not in EventTypeCatalogue::MAP' )); $event = ['path' => 'HEK>>NewConcept>>SomeFRM', 'id' => 'x']; diff --git a/tests/unit_tests/events/LegacyEventsStringParserTest.php b/tests/unit_tests/events/LegacyEventsStringParserTest.php new file mode 100644 index 00000000..1a6b7def --- /dev/null +++ b/tests/unit_tests/events/LegacyEventsStringParserTest.php @@ -0,0 +1,124 @@ + + */ + +use PHPUnit\Framework\TestCase; +use Helioviewer\Api\Event\LegacyEventsStringParser; + +final class LegacyEventsStringParserTest extends TestCase +{ + public static function legacyStringProvider(): array + { + return [ + 'all_frms_hek' => [ + '[AR,all,1]', + ['HEK>>Active Region'], + ], + 'specific_frm' => [ + '[FL,NOAA_SWPC,1]', + ['HEK>>Flare>>NOAA_SWPC'], + ], + 'semicolon_separated_frms' => [ + '[FL,NOAA_SWPC;SPoCA,1]', + ['HEK>>Flare>>NOAA_SWPC', 'HEK>>Flare>>SPoCA'], + ], + 'ccmc_source' => [ + '[C3,all,1]', + ['CCMC>>DONKI'], + ], + 'rhessi_source' => [ + '[F2,all,1]', + ['RHESSI>>Solar Flares'], + ], + 'wsa_source' => [ + '[MC,SO,1]', + ['WSA>>Magnetic Connectivity>>SO'], + ], + 'multiple_groups' => [ + '[AR,all,1],[FL,all,1]', + ['HEK>>Active Region', 'HEK>>Flare'], + ], + 'cross_source' => [ + '[AR,all,1],[C3,all,1]', + ['HEK>>Active Region', 'CCMC>>DONKI'], + ], + 'unknown_event_type_skipped' => [ + '[XX,all,1]', + [], + ], + 'empty_frms_treated_as_all' => [ + '[AR,,1]', + ['HEK>>Active Region'], + ], + 'empty_string' => [ + '', + [], + ], + 'only_two_pieces_skipped' => [ + '[AR,all]', + [], + ], + 'unknown_in_middle_skipped' => [ + '[AR,all,1],[XX,all,1],[FL,all,1]', + ['HEK>>Active Region', 'HEK>>Flare'], + ], + 'empty_brackets' => [ + '[]', + [], + ], + 'multiple_empty_brackets' => [ + '[],[]', + [], + ], + 'empty_bracket_with_valid' => [ + '[],[AR,all,1]', + ['HEK>>Active Region'], + ], + 'too_many_commas' => [ + '[,,,]', + [], + ], + 'valid_mixed_with_empty_brackets' => [ + '[AR,all,1],[],[]', + ['HEK>>Active Region'], + ], + ]; + } + + /** + * @dataProvider legacyStringProvider + */ + public function testItParsesLegacyStringsToPaths(string $input, array $expected): void + { + $this->assertSame($expected, LegacyEventsStringParser::parse($input)); + } + + public function testSelectionsFromUrlParamsPairsPathsWithVisibility(): void + { + [$selections, $visibility] = LegacyEventsStringParser::selectionsFromUrlParams('[AR,all,1],[MC,SO,1]', true); + + $this->assertSame(['HEK>>Active Region', 'WSA>>Magnetic Connectivity>>SO'], $selections); + + // One entry per known source; markers always visible, labels from the flag. + $this->assertEqualsCanonicalizing(['HEK', 'CCMC', 'RHESSI', 'WSA'], array_keys($visibility)); + foreach ($visibility as $flags) { + $this->assertSame(['marker_visibility' => true, 'label_visibility' => true], $flags); + } + } + + public function testSelectionsFromUrlParamsPropagatesLabelsFlagFalse(): void + { + [, $visibility] = LegacyEventsStringParser::selectionsFromUrlParams('[AR,all,1]', false); + + $this->assertFalse($visibility['HEK']['label_visibility']); + $this->assertTrue($visibility['HEK']['marker_visibility']); + } +} From 70ea69228752bffafd01ca2c65b1578a44f4a26f Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 27 Jul 2026 22:50:19 +0000 Subject: [PATCH 13/31] takeScreenshot/queueMovie drop EventsStateManager. takeScreenshot gains optional event_selections= (semicolon-separated SOURCE>>Label[>>FRM]), preferred over legacy events=; eventLabels applies to both. Both persist the new event_selections + event_visibility_selections blob. --- src/Event/LegacyEventsStringParser.php | 14 ++++++-- src/Module/Movies.php | 18 +++++++---- src/Module/WebClient.php | 45 ++++++++++++++------------ 3 files changed, 48 insertions(+), 29 deletions(-) diff --git a/src/Event/LegacyEventsStringParser.php b/src/Event/LegacyEventsStringParser.php index 25b5a21c..c8703d70 100644 --- a/src/Event/LegacyEventsStringParser.php +++ b/src/Event/LegacyEventsStringParser.php @@ -108,8 +108,18 @@ public static function parse(string $events_state_string): array */ public static function selectionsFromUrlParams(string $events_state_string, bool $labels): array { - $selections = self::parse($events_state_string); + return [self::parse($events_state_string), self::visibilityFromLabels($labels)]; + } + /** + * Build a per-source visibility map from the global eventLabels flag. + * Markers are always visible (the legacy URL API has no per-source marker + * toggle); label_visibility comes from $labels. One entry per known source. + * + * @return array + */ + public static function visibilityFromLabels(bool $labels): array + { $visibility = []; foreach (array_keys(EventTypeCatalogue::MAP) as $source) { $visibility[$source] = [ @@ -118,6 +128,6 @@ public static function selectionsFromUrlParams(string $events_state_string, bool ]; } - return [$selections, $visibility]; + return $visibility; } } diff --git a/src/Module/Movies.php b/src/Module/Movies.php index 9bb0aafe..4cb6c1a8 100644 --- a/src/Module/Movies.php +++ b/src/Module/Movies.php @@ -14,7 +14,7 @@ */ use Helioviewer\Api\Module\BaseModule; use Helioviewer\Api\Module\ModuleInterface; -use Helioviewer\Api\Event\EventsStateManager; +use Helioviewer\Api\Event\LegacyEventsStringParser; use Helioviewer\Api\Sentry\Sentry; class Module_Movies extends BaseModule implements ModuleInterface { @@ -307,11 +307,15 @@ public function queueMovie() { $event_labels = (bool)$this->_params['eventLabels']; } - // ATTENTION! These two fields eventsLabels and eventSourceString needs to be kept in DB schema - // We are keeping them to support old takeScreenshot , queueMovie requests - - // Events manager built from old logic - $events_manager = EventsStateManager::buildFromLegacyEventStrings($events_legacy_string, $event_labels); + // Parse the legacy ?events= bracket-string into the canonical shape and + // persist it verbatim, so the movie worker rehydrates it exactly like a + // JSON postMovie row. + [$selections, $visibilitySelections] = + LegacyEventsStringParser::selectionsFromUrlParams($events_legacy_string, $event_labels); + $events_state_blob = json_encode([ + 'event_selections' => $selections, + 'event_visibility_selections' => $visibilitySelections, + ]); // TODO 2012/04/11 // Discard any layers which do not share an overlap with the roi to @@ -368,7 +372,7 @@ public function queueMovie() { $options['watermark'], $this->_params['layers'], $bitmask, - $events_manager->export(), + $events_state_blob, (isset($this->_params['movieIcons']) ? $this->_params['movieIcons'] : false), (isset($this->_params['followViewport']) ? $this->_params['followViewport'] : false), $options['scale'], diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index 503c1d9a..3a9c3a88 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -18,7 +18,7 @@ use Helioviewer\Api\Module\BaseModule; use Helioviewer\Api\Module\ModuleInterface; -use Helioviewer\Api\Event\EventsStateManager; +use Helioviewer\Api\Event\LegacyEventsStringParser; use Helioviewer\Api\Event\EventContext; use Helioviewer\Api\Event\Timeline\Timeline as EventTimeline; use Helioviewer\Api\Event\Api\EventsApiException; @@ -614,30 +614,34 @@ public function takeScreenshot() { ); } - // Event legacy string - $events_legacy_string = ""; - if ( array_key_exists('events', $this->_params) ) { - $events_legacy_string = $this->_params['events']; - } - - // Event legacy labels switch - $event_labels = false; - if ( array_key_exists('eventLabels', $this->_params) ) { - $event_labels = (bool)$this->_params['eventLabels']; + // eventLabels sets per-source label visibility (markers always on) and + // applies to whichever event param is used. Optional; defaults off. + $event_labels = array_key_exists('eventLabels', $this->_params) ? (bool)$this->_params['eventLabels'] : false; + $visibilitySelections = LegacyEventsStringParser::visibilityFromLabels($event_labels); + + // Selections. Precedence: event_selections (canonical) first, then the + // legacy ?events= bracket-string, then none. Both params are optional. + // The composite persists $selections + $visibilitySelections (via + // EventContext) as the new-shape blob -- so a URL-params screenshot row is + // indistinguishable from a JSON postScreenshot row. + if ( array_key_exists('event_selections', $this->_params) ) { + // Canonical: semicolon-separated "SOURCE>>Label[>>FRM]" paths, e.g. + // event_selections=HEK>>Active Region;WSA>>Magnetic Connectivity>>SO + $parts = array_map('trim', explode(';', $this->_params['event_selections'])); + $selections = array_values(array_filter($parts, fn($p) => $p !== '')); + } elseif ( array_key_exists('events', $this->_params) ) { + // Legacy bracket-string. + $selections = LegacyEventsStringParser::parse($this->_params['events']); + } else { + $selections = []; } - - // ATTENTION! These two fields eventsLabels and eventSourceString needs to be kept in DB schema - // We are keeping them to support old takeScreenshot , queueMovie requests - // Events manager built from old logic - $events_manager = EventsStateManager::buildFromLegacyEventStrings($events_legacy_string, $event_labels); - $screenshotDate = $this->_params['date']; $totalStart = microtime(true); $eventContext = EventContext::build( frameTimestamps: [$screenshotDate], - selections: $events_manager->getSelections(), - visibilitySelections: $events_manager->getVisibilitySelections(), + selections: $selections, + visibilitySelections: $visibilitySelections, api: $this->eventsApi(), logLabel: "Screenshot:{$screenshotDate}", ); @@ -1808,7 +1812,7 @@ public function getValidationRules(): array { 'required' => array('date', 'imageScale', 'layers'), 'optional' => array('display', 'watermark', 'x1', 'x2', 'y1', 'y2', 'x0', 'y0', 'width', 'height', - 'events', 'eventLabels', 'movieIcons', 'scale', + 'events', 'event_selections', 'eventLabels', 'movieIcons', 'scale', 'scaleType', 'scaleX', 'scaleY', 'callback', 'switchSources', 'celestialBodiesLabels', 'celestialBodiesTrajectories'), 'floats' => array('imageScale', 'x1', 'x2', 'y1', 'y2', @@ -1819,6 +1823,7 @@ public function getValidationRules(): array { 'scale', 'movieIcons', 'switchSources'), 'alphanum' => array('scaleType', 'callback', 'celestialBodiesLabels', 'celestialBodiesTrajectories'), 'legacy_event_string' => array('events'), + 'any' => array('event_selections'), 'choices' => array('scaleType' => ['earth', 'scalebar']), 'layer' => array('layers') ); From b8631f2667160af4379a98ad97de0052af31c131 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 27 Jul 2026 23:29:45 +0000 Subject: [PATCH 14/31] Retire EventSelections; Timeline takes canonical paths --- src/Event/EventSelections.php | 199 ------------------ src/Event/EventsStateManager.php | 4 +- src/Event/Timeline/CoverageInterface.php | 2 +- src/Event/Timeline/Timeline.php | 49 +---- src/Module/WebClient.php | 7 +- .../unit_tests/events/EventSelectionsTest.php | 116 ---------- .../events/timeline/TimelineTest.php | 6 +- 7 files changed, 22 insertions(+), 361 deletions(-) delete mode 100644 src/Event/EventSelections.php delete mode 100644 tests/unit_tests/events/EventSelectionsTest.php diff --git a/src/Event/EventSelections.php b/src/Event/EventSelections.php deleted file mode 100644 index 86df9878..00000000 --- a/src/Event/EventSelections.php +++ /dev/null @@ -1,199 +0,0 @@ - - * @license http://www.mozilla.org/MPL/MPL-1.1.html Mozilla Public License 1.1 - * @link https://github.com/Helioviewer-Project - */ - -namespace Helioviewer\Api\Event; - -use ArrayAccess; -use Countable; -use IteratorAggregate; -use ArrayIterator; -use Traversable; - -class EventSelections implements ArrayAccess, Countable, IteratorAggregate -{ - public static array $event_types_map = [ - 'HEK' => [ - 'AR' => 'Active Region', - 'CE' => 'CME', - 'CH' => 'Coronal Hole', - 'EF' => 'Emerging Flux', - 'FI' => 'Filament', - 'FL' => 'Flare', - 'SG' => 'Sigmoid', - 'CC' => 'Coronal Cavity', - 'CD' => 'Coronal Dimming', - 'CJ' => 'Coronal Jet', - 'CR' => 'Coronal Rain', - 'CW' => 'Coronal Wave', - 'ER' => 'Eruption', - 'FA' => 'Filament Activation', - 'FE' => 'Filament Eruption', - 'LP' => 'Loop', - 'OS' => 'Oscillation', - 'PG' => 'Plage', - 'SP' => 'Spray Surge', - 'SS' => 'Sunspot', - 'OT' => 'Other', - 'NR' => 'Nothing Reported', - 'TO' => 'Topological Object', - 'HY' => 'Hypothesis', - 'BU' => 'UVBurst', - 'EE' => 'Explosive Event', - 'PB' => 'Prominence Bubble', - 'PT' => 'Peacock Tail', - 'EP' => 'SEPs', - 'IC' => 'ICMEs', - 'SR' => 'SIRs', - // 'UNK' => 'Unknown', // Not in events API - ], - 'CCMC' => [ - 'C3' => 'DONKI', - 'FP' => 'Solar Flare Predictions', - ], - 'RHESSI' => [ - 'F2' => 'Solar Flares', - ], - 'WSA' => [ - 'MC' => 'Magnetic Connectivity', - 'CH' => 'Coronal Hole', - ], - ]; - - private array $selections; - - /** - * Creates a new EventSelections - * @param array $selections Array of selection strings like 'HEK>>Active Region>>Spoca' - */ - private function __construct(array $selections) - { - $this->selections = $selections; - } - - /** - * Creates a new EventSelections from legacy event string - * @param string $events_state_string Legacy event string like "[AR,all,1],[FL,NOAA_SWPC,1]" - * @return EventSelections - */ - /** - * Creates a new EventSelections from a pre-parsed list of selection paths. - * Used by callers (e.g. eventsDataCoverage POST body) that already speak the - * canonical SOURCE>>Label>>FRM shape and don't need the legacy-string parse. - * - * @param string[] $paths Selection paths like 'HEK>>Active Region>>SPoCA' - * @return EventSelections - */ - public static function buildFromPaths(array $paths): EventSelections - { - return new self(array_values($paths)); - } - - public static function buildFromLegacyEventStrings(string $events_state_string): EventSelections - { - $selections = []; - - // Prevent possible bugs - $events_state_string = trim($events_state_string); - - if (!empty($events_state_string)) { - $stripped = stripslashes($events_state_string); - // Remove only the outermost [ and ] - if (str_starts_with($stripped, '[') && str_ends_with($stripped, ']')) { - $stripped = substr($stripped, 1, -1); - } - $event_strings = explode("],[", $stripped); - - // Process individual events in string - foreach ($event_strings as $es) { - - $event_pieces = explode(",", $es); - - // there should be 3 elements - if (count($event_pieces) < 3) { - continue; - } - - list($event_type, $combined_frms, $visible) = $event_pieces; - - // Find the source (HEK, CCMC, RHESSI) and label for this event_type - $source = null; - $label = null; - foreach (self::$event_types_map as $src => $types) { - if (array_key_exists($event_type, $types)) { - $source = $src; - $label = $types[$event_type]; - break; - } - } - - // Skip if event_type not found in map - if ($source === null || $label === null) { - continue; - } - - $frms = explode(";", $combined_frms); - - // If 'all' or empty frms, just use SOURCE>>LABEL - if (empty($combined_frms) || $combined_frms === 'all' || in_array('all', $frms)) { - $selections[] = $source . '>>' . $label; - } else { - // For each specific FRM, create SOURCE>>LABEL>>FRM - foreach ($frms as $frm) { - $frm = trim($frm); - if (!empty($frm)) { - $selections[] = $source . '>>' . $label . '>>' . $frm; - } - } - } - } - } - - return new self($selections); - } - - // IteratorAggregate implementation - public function getIterator(): Traversable - { - return new ArrayIterator($this->selections); - } - - // Countable implementation - public function count(): int - { - return count($this->selections); - } - - // ArrayAccess implementation - public function offsetExists($offset): bool - { - return isset($this->selections[$offset]); - } - - public function offsetGet($offset): mixed - { - return $this->selections[$offset] ?? null; - } - - public function offsetSet($offset, $value): void - { - if (is_null($offset)) { - $this->selections[] = $value; - } else { - $this->selections[$offset] = $value; - } - } - - public function offsetUnset($offset): void - { - unset($this->selections[$offset]); - } -} diff --git a/src/Event/EventsStateManager.php b/src/Event/EventsStateManager.php index 249c64d5..3b575e70 100644 --- a/src/Event/EventsStateManager.php +++ b/src/Event/EventsStateManager.php @@ -413,8 +413,8 @@ public function getSelections(): array // ('HEK', 'AR') -> 'Active Region' // ('HEK', 'FL') -> 'Flare' // ('CCMC', 'FP') -> 'Solar Flare Predictions' - // Unknown pin -> null -> skip (matches EventSelections behavior) - $label = EventSelections::$event_types_map[$source][$pin] ?? null; + // Unknown pin -> null -> skip + $label = EventTypeCatalogue::MAP[$source][$pin] ?? null; if ($label === null) { // Pin isn't in the map - usually means a typo or a new // event type we haven't registered yet. Visible signal, diff --git a/src/Event/Timeline/CoverageInterface.php b/src/Event/Timeline/CoverageInterface.php index be882cd8..fe93db52 100644 --- a/src/Event/Timeline/CoverageInterface.php +++ b/src/Event/Timeline/CoverageInterface.php @@ -12,7 +12,7 @@ interface CoverageInterface /** * @param EventsApiInterface $eventsApi * @param TimeRange $range Extended time range - * @param array $paths Selection paths from EventSelections + * @param array $paths Canonical SOURCE>>Label[>>FRM] selection paths * @param string $resolution Resolution string * @return array Series data for the timeline */ diff --git a/src/Event/Timeline/Timeline.php b/src/Event/Timeline/Timeline.php index a5217952..a757885f 100644 --- a/src/Event/Timeline/Timeline.php +++ b/src/Event/Timeline/Timeline.php @@ -4,7 +4,6 @@ use Helioviewer\Api\Event\Api\EventsApi; use Helioviewer\Api\Event\Api\EventsApiInterface; -use Helioviewer\Api\Event\EventSelections; /** * Orchestrator for event coverage timeline queries. @@ -14,19 +13,21 @@ * and returns JSON for the frontend timeline. * * Usage: - * $timeline = new Timeline($eventLayers, $start, $end, $current, $eventsApi); + * $timeline = new Timeline($paths, $start, $end, $current, $eventsApi); * echo $timeline->execute(); */ class Timeline { private TimeRange $range; private string $resolution; - private EventSelections $eventSelections; + /** @var string[] canonical SOURCE>>Label[>>FRM] selection paths */ + private array $paths; private EventsApiInterface $eventsApi; private CoverageInterface $strategy; /** - * @param string $eventLayers Legacy event string e.g. '[AR,all,1],[FL,NOAA_SWPC,1]' + * @param string[] $paths Canonical selection paths, e.g. + * ['HEK>>Active Region', 'WSA>>Magnetic Connectivity>>SO'] * @param mixed $startTimestamp Start time in milliseconds * @param mixed $endTimestamp End time in milliseconds * @param mixed $currentTimestamp Current observation time in milliseconds @@ -35,7 +36,7 @@ class Timeline * @throws \InvalidArgumentException If timestamps are invalid */ public function __construct( - string $eventLayers, + array $paths, $startTimestamp, $endTimestamp, $currentTimestamp, @@ -48,8 +49,8 @@ public function __construct( // Calculate resolution from the time range $this->resolution = Resolution::fromRange($this->range->range()); - // Parse legacy event string into API selection paths - $this->eventSelections = EventSelections::buildFromLegacyEventStrings($eventLayers); + // Canonical selection paths, as sent by the eventsDataCoverage POST body. + $this->paths = array_values($paths); // API client — use provided or create default $this->eventsApi = $eventsApi ?? new EventsApi(); @@ -60,33 +61,6 @@ public function __construct( : new AggregatedCoverage()); } - /** - * Factory for callers that already have pre-parsed selection paths - * (e.g. the eventsDataCoverage POST body). Skips the legacy-string parser. - * - * @param string[] $paths Selection paths (e.g. ['HEK>>Active Region', 'WSA>>Magnetic Connectivity']) - * @param mixed $startTimestamp ms epoch - * @param mixed $endTimestamp ms epoch - * @param mixed $currentTimestamp ms epoch - * @param EventsApiInterface|null $eventsApi - * @param CoverageInterface|null $strategy - */ - public static function fromPaths( - array $paths, - $startTimestamp, - $endTimestamp, - $currentTimestamp, - ?EventsApiInterface $eventsApi = null, - ?CoverageInterface $strategy = null - ): self { - // Construct with an empty legacy string, then replace eventSelections - // with the pre-parsed paths. The empty string is a no-op inside - // buildFromLegacyEventStrings so this is safe. - $instance = new self('', $startTimestamp, $endTimestamp, $currentTimestamp, $eventsApi, $strategy); - $instance->eventSelections = EventSelections::buildFromPaths($paths); - return $instance; - } - /** * Execute the timeline query and return JSON string. * @@ -94,12 +68,10 @@ public static function fromPaths( */ public function execute(): string { - $paths = iterator_to_array($this->eventSelections); - $data = $this->strategy->execute( $this->eventsApi, $this->range, - $paths, + $this->paths, $this->resolution ); @@ -109,5 +81,6 @@ public function execute(): string // Getters for testing public function getResolution(): string { return $this->resolution; } public function getRange(): TimeRange { return $this->range; } - public function getEventSelections(): EventSelections { return $this->eventSelections; } + /** @return string[] */ + public function getPaths(): array { return $this->paths; } } diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index 3a9c3a88..7177d89d 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -1006,8 +1006,11 @@ public function getDataCoverage() { return $this->getDataCoverageForLayers(); } else if (!empty($this->_options['eventLayers'])) { try { + // Legacy callers still pass the bracket-string; parse it into + // canonical paths for the (now path-based) Timeline. + $paths = LegacyEventsStringParser::parse($this->_options['eventLayers']); $timeline = new EventTimeline( - $this->_options['eventLayers'], + $paths, $this->_options['startDate'] ?? null, $this->_options['endDate'] ?? null, $this->_options['currentDate'] ?? null, @@ -1044,7 +1047,7 @@ public function eventsDataCoverage() { $json_params = $this->_params['json'] ?? []; $paths = $json_params['event_selections'] ?? []; - $timeline = EventTimeline::fromPaths( + $timeline = new EventTimeline( $paths, $this->_params['startDate'] ?? null, $this->_params['endDate'] ?? null, diff --git a/tests/unit_tests/events/EventSelectionsTest.php b/tests/unit_tests/events/EventSelectionsTest.php deleted file mode 100644 index 2d59e28d..00000000 --- a/tests/unit_tests/events/EventSelectionsTest.php +++ /dev/null @@ -1,116 +0,0 @@ - - */ - -use PHPUnit\Framework\TestCase; -use Helioviewer\Api\Event\EventSelections; - -final class EventSelectionsTest extends TestCase -{ - public static function legacyStringProvider(): array - { - return [ - 'all_frms_hek' => [ - '[AR,all,1]', - ['HEK>>Active Region'], - ], - 'specific_frm' => [ - '[FL,NOAA_SWPC,1]', - ['HEK>>Flare>>NOAA_SWPC'], - ], - 'semicolon_separated_frms' => [ - '[FL,NOAA_SWPC;SPoCA,1]', - ['HEK>>Flare>>NOAA_SWPC', 'HEK>>Flare>>SPoCA'], - ], - 'ccmc_source' => [ - '[C3,all,1]', - ['CCMC>>DONKI'], - ], - 'rhessi_source' => [ - '[F2,all,1]', - ['RHESSI>>Solar Flares'], - ], - 'multiple_groups' => [ - '[AR,all,1],[FL,all,1]', - ['HEK>>Active Region', 'HEK>>Flare'], - ], - 'cross_source' => [ - '[AR,all,1],[C3,all,1]', - ['HEK>>Active Region', 'CCMC>>DONKI'], - ], - 'unknown_event_type_skipped' => [ - '[XX,all,1]', - [], - ], - 'empty_frms_treated_as_all' => [ - '[AR,,1]', - ['HEK>>Active Region'], - ], - 'empty_string' => [ - '', - [], - ], - 'only_two_pieces_skipped' => [ - '[AR,all]', - [], - ], - 'unknown_in_middle_skipped' => [ - '[AR,all,1],[XX,all,1],[FL,all,1]', - ['HEK>>Active Region', 'HEK>>Flare'], - ], - 'empty_brackets' => [ - '[]', - [], - ], - 'multiple_empty_brackets' => [ - '[],[]', - [], - ], - 'empty_bracket_with_valid' => [ - '[],[AR,all,1]', - ['HEK>>Active Region'], - ], - 'too_many_commas' => [ - '[,,,]', - [], - ], - 'valid_mixed_with_empty_brackets' => [ - '[AR,all,1],[],[]', - ['HEK>>Active Region'], - ], - ]; - } - - /** - * @dataProvider legacyStringProvider - */ - public function testItShouldBuildCorrectSelectionsFromLegacyString(string $input, array $expected): void - { - $selections = EventSelections::buildFromLegacyEventStrings($input); - $this->assertEquals($expected, iterator_to_array($selections)); - } - - public function testItShouldBeCountableIterableAndArrayAccessible(): void - { - $selections = EventSelections::buildFromLegacyEventStrings('[AR,all,1],[FL,all,1]'); - - // Countable - $this->assertCount(2, $selections); - - // Iterable - $paths = []; - foreach ($selections as $path) { - $paths[] = $path; - } - $this->assertEquals(['HEK>>Active Region', 'HEK>>Flare'], $paths); - - // ArrayAccess - $this->assertEquals('HEK>>Active Region', $selections[0]); - $this->assertEquals('HEK>>Flare', $selections[1]); - $this->assertTrue(isset($selections[0])); - $this->assertFalse(isset($selections[99])); - $this->assertNull($selections[99]); - } -} diff --git a/tests/unit_tests/events/timeline/TimelineTest.php b/tests/unit_tests/events/timeline/TimelineTest.php index 0d323d27..efdac833 100644 --- a/tests/unit_tests/events/timeline/TimelineTest.php +++ b/tests/unit_tests/events/timeline/TimelineTest.php @@ -34,7 +34,7 @@ public function testItShouldPassCorrectParamsToStrategy(): void && $range->extendedStart() === -1000 && $range->extendedEnd() === 5000; }), - // [AR,all,1],[C3,all,1] → paths + // canonical paths, passed straight through $this->equalTo(['HEK>>Active Region', 'CCMC>>DONKI']), // Range 2000ms → minute resolution $this->equalTo('m') @@ -42,7 +42,7 @@ public function testItShouldPassCorrectParamsToStrategy(): void ->willReturn([]); $timeline = new Timeline( - '[AR,all,1],[C3,all,1]', 1000, 3000, 2000, + ['HEK>>Active Region', 'CCMC>>DONKI'], 1000, 3000, 2000, $this->mockEventsApi, $this->mockStrategy ); @@ -60,7 +60,7 @@ public function testItShouldReturnJsonEncodedStrategyOutput(): void $this->mockStrategy->method('execute')->willReturn($strategyOutput); $timeline = new Timeline( - '[AR,all,1]', 1000, 2000, 1500, + ['HEK>>Active Region'], 1000, 2000, 1500, $this->mockEventsApi, $this->mockStrategy ); From 9237a832a7add19c42237a5eeefd026aa97d2c86 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 28 Jul 2026 05:18:35 +0000 Subject: [PATCH 15/31] implement migration scripts for screenshots, movies, sharedurls --- Makefile | 26 ++++- docroot/schema/client_state.schema.json | 10 +- management/events/events_shape.php | 108 ++++++++++++++++++ .../migrate_client_states_events_shape.php | 72 ++++++++++++ .../events/migrate_movies_events_shape.php | 68 +++++++++++ .../migrate_screenshots_events_shape.php | 67 +++++++++++ 6 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 management/events/events_shape.php create mode 100644 management/events/migrate_client_states_events_shape.php create mode 100644 management/events/migrate_movies_events_shape.php create mode 100644 management/events/migrate_screenshots_events_shape.php diff --git a/Makefile b/Makefile index 3b2393c9..1e3c6036 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,12 @@ DOCKER_EXEC = docker compose exec api PHPUNIT = $(DOCKER_EXEC) vendor/bin/phpunit --bootstrap tests/autoload.php --testdox -.PHONY: help test event-tests regression-tests test-filter test-file shell exec restart-movies db-shell +# Events-shape migration runners. Dry-run by default; pass APPLY=1 to write. +MIGRATE_EVENTS = $(DOCKER_EXEC) php management/events +APPLY_FLAG = $(if $(APPLY),--apply,) + +.PHONY: help test event-tests regression-tests test-filter test-file shell exec restart-movies db-shell \ + migrate-events migrate-screenshots migrate-movies migrate-client-states .DEFAULT_GOAL := help @@ -20,6 +25,12 @@ help: @echo " make exec cmd=\"...\" - Run a command in api container" @echo " make restart-movies - Restart movies container" @echo " make db-shell - Open MySQL shell" + @echo " make migrate-events - Events-shape migration, all tables (dry-run)" + @echo " lists unique tree=>shape conversions, writes nothing" + @echo " add APPLY=1 to actually write, e.g. make migrate-events APPLY=1" + @echo " make migrate-screenshots - Only the screenshots table (dry-run; APPLY=1 to write)" + @echo " make migrate-movies - Only the movies table (dry-run; APPLY=1 to write)" + @echo " make migrate-client-states - Only the client_states table (dry-run; APPLY=1 to write)" shell: $(DOCKER_EXEC) bash @@ -57,3 +68,16 @@ restart-movies: db-shell: docker compose exec database mariadb -u helioviewer -phelioviewer helioviewer + +# Events-shape migration. Each target is dry-run by default (lists unique +# tree=>shape conversions, no writes); pass APPLY=1 to perform the UPDATEs. +migrate-screenshots: + $(MIGRATE_EVENTS)/migrate_screenshots_events_shape.php $(APPLY_FLAG) + +migrate-movies: + $(MIGRATE_EVENTS)/migrate_movies_events_shape.php $(APPLY_FLAG) + +migrate-client-states: + $(MIGRATE_EVENTS)/migrate_client_states_events_shape.php $(APPLY_FLAG) + +migrate-events: migrate-screenshots migrate-movies migrate-client-states diff --git a/docroot/schema/client_state.schema.json b/docroot/schema/client_state.schema.json index 930d3276..13cafc6a 100644 --- a/docroot/schema/client_state.schema.json +++ b/docroot/schema/client_state.schema.json @@ -10,8 +10,11 @@ "centerX": { "type": "number" }, "centerY": { "type": "number" }, "imageScale": { "type": "number" }, - "eventLayers": { - "$ref": "https://api.helioviewer.org/schema/event_layers.schema.json" + "event_selections": { + "$ref": "https://api.helioviewer.org/schema/event_selections.schema.json" + }, + "event_visibility_selections": { + "$ref": "https://api.helioviewer.org/schema/event_visibility_selections.schema.json" }, "imageLayers": { "type": "array", @@ -29,7 +32,8 @@ "centerX", "centerY", "imageScale", - "eventLayers", + "event_selections", + "event_visibility_selections", "imageLayers", "celestialBodies" ] diff --git a/management/events/events_shape.php b/management/events/events_shape.php new file mode 100644 index 00000000..42417a69 --- /dev/null +++ b/management/events/events_shape.php @@ -0,0 +1,108 @@ +>Active Region", ...], + * "event_visibility_selections": { "HEK": {"marker_visibility":true, + * "label_visibility":false}, ... } + * } + * + * EventsStateManager is the one-shot adapter: it already knows how to walk the + * legacy tree and emit canonical selection paths. An empty/absent tree yields + * empty selections and all-true visibility, so the same path covers the rows + * whose blob is {} (no events were ever selected). + * + * Included by the three management/events/migrate_*_events_shape.php runners; + * all four files retire in the post-deploy cleanup alongside EventsStateManager. + * + * @category Migration + * @package Helioviewer + */ + +use Helioviewer\Api\Event\EventsStateManager; +use Helioviewer\Api\Event\Api\EventsApi; +use Helioviewer\Api\Sentry\Sentry; + +// EventsStateManager::getSelections() reports unmapped event pins to Sentry. +// These CLI migrations run outside the web bootstrap that normally initializes +// it, so ensure a (disabled, VoidClient) instance exists — an empty config +// defaults to disabled — otherwise those reports throw. No-op if the caller +// already initialized Sentry. +if (Sentry::$client === null) { + Sentry::init([]); +} + +/** + * @param array $tree Legacy events tree (tree_HEK / tree_CCMC / ...), or []. + * @return array{event_selections: string[], event_visibility_selections: array} + */ +function events_shape_from_tree(array $tree): array +{ + // Canonical selection paths — the manager does the legacy-tree walk. + $selections = EventsStateManager::buildFromEventsState($tree)->getSelections(); + + // Per-source visibility. The legacy tree stores markers_visible / + // labels_visible per tree_SOURCE; a missing flag means "on" by historical + // convention (matches the manager's own default). + $visibility = []; + foreach (EventsApi::VALID_SOURCES as $source) { + $group = $tree['tree_' . $source] ?? []; + $visibility[$source] = [ + 'marker_visibility' => (bool)($group['markers_visible'] ?? true), + 'label_visibility' => (bool)($group['labels_visible'] ?? true), + ]; + } + + return [ + 'event_selections' => $selections, + 'event_visibility_selections' => $visibility, + ]; +} + +/** + * Accumulate a conversion into $bucket keyed by its unique signature, so many + * rows that share the same "tree => shape" conversion collapse into a single + * listed entry (with a count and a sample id). $bucket is passed by reference. + * + * @param array $bucket accumulator, keyed by conversion signature + * @param string $sampleId a human label for one row with this conversion + * @param array $tree the legacy input tree (or [] when the row had none) + * @param array $shape the derived new-shape blob + */ +function events_migration_collect(array &$bucket, string $sampleId, array $tree, array $shape): void +{ + $sig = json_encode([$tree, $shape]); + if (!isset($bucket[$sig])) { + $bucket[$sig] = ['count' => 0, 'sample' => $sampleId, 'tree' => $tree, 'shape' => $shape]; + } + $bucket[$sig]['count']++; +} + +/** + * Print each UNIQUE conversion once — "tree => event_selections & + * event_visibility_selections" — separated by a banner line so they are easy to + * scan. Identical conversions are folded together with a row count. + * + * @param array $bucket the accumulator filled by events_migration_collect() + * @param string $table table name, for the per-entry heading + */ +function events_migration_print_unique(array $bucket, string $table): void +{ + $json = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + $sep = str_repeat('*', 60); + + echo $sep . "\n"; + foreach ($bucket as $entry) { + echo "{$table}: {$entry['count']} row(s), e.g. {$entry['sample']}\n"; + echo " tree => " . ($entry['tree'] === [] ? "{} (no events)" : json_encode($entry['tree'], $json)) . "\n"; + echo " event_selections => " . json_encode($entry['shape']['event_selections'], $json) . "\n"; + echo " event_visibility_selections => " . json_encode($entry['shape']['event_visibility_selections'], $json) . "\n"; + echo $sep . "\n"; + } + echo count($bucket) . " unique conversion(s)\n"; +} diff --git a/management/events/migrate_client_states_events_shape.php b/management/events/migrate_client_states_events_shape.php new file mode 100644 index 00000000..d07faf23 --- /dev/null +++ b/management/events/migrate_client_states_events_shape.php @@ -0,0 +1,72 @@ +) resolve against it, so the id MUST stay + * byte-identical. This uses ClientState::update() (UPDATE at a fixed id), + * never upsert() (which re-hashes and would orphan every existing URL). + * Post-deploy saves still go through upsert(), correctly hashing new-shape JSON. + * + * - DRY-RUN BY DEFAULT. It only writes when passed --apply. Either way it + * prints "eventLayers => event_selections & event_visibility_selections" + * for every row it would migrate, so the mapping can be validated first. + * - Idempotent + resumable: rows already carrying event_selections are skipped. + * - Runs inside the maintenance window, after the new code + schema are deployed. + * + * Retired in the post-deploy cleanup (with EventsStateManager). + * + * Usage: + * php management/events/migrate_client_states_events_shape.php # dry-run + * php management/events/migrate_client_states_events_shape.php --apply # write + */ + +require_once sprintf('%s/../../vendor/autoload.php', __DIR__); +require_once sprintf('%s/../config.php', __DIR__); +require_once sprintf('%s/../../src/Database/ClientState.php', __DIR__); +require_once sprintf('%s/events_shape.php', __DIR__); + +$apply = in_array('--apply', $argv, true); +echo $apply + ? "MODE: APPLY — writing changes to client_states\n\n" + : "MODE: DRY-RUN — no writes; re-run with --apply to write\n\n"; + +$client_state = new ClientState(); +$rows = $client_state->all(1000000); + +$migrated = 0; +$skipped = 0; +$conversions = []; +foreach ($rows as $row) { + $id = $row['id']; // SHA-256 hex — MUST stay the same + $state = json_decode($row['state'], true) ?? []; + + // Already new-shape — leave it alone (idempotency / resume). + if (isset($state['event_selections'])) { + $skipped++; + continue; + } + + $tree = (isset($state['eventLayers']) && is_array($state['eventLayers'])) + ? $state['eventLayers'] + : []; + $shape = events_shape_from_tree($tree); + events_migration_collect($conversions, $id, $tree, $shape); + + $state['event_selections'] = $shape['event_selections']; + $state['event_visibility_selections'] = $shape['event_visibility_selections']; + unset($state['eventLayers']); // deployed code + schema no longer carry it + + if ($apply) { + // UPDATE at the fixed id — preserves the shared-URL hash. + $client_state->update($id, $state); + } + $migrated++; +} + +events_migration_print_unique($conversions, 'client_states'); + +$verb = $apply ? "migrated" : "would migrate"; +echo "client_states: $verb $migrated, skipped $skipped (already new-shape)\n"; diff --git a/management/events/migrate_movies_events_shape.php b/management/events/migrate_movies_events_shape.php new file mode 100644 index 00000000..cc0596f2 --- /dev/null +++ b/management/events/migrate_movies_events_shape.php @@ -0,0 +1,68 @@ + event_selections & event_visibility_selections" for every + * row it would migrate, so the mapping can be validated before committing. + * - Idempotent + resumable: rows already carrying event_selections are skipped. + * - The primary key (auto-inc id) is never touched. + * - Runs inside the maintenance window, after the new code is deployed. + * + * Retired in the post-deploy cleanup (with EventsStateManager). + * + * Usage: + * php management/events/migrate_movies_events_shape.php # dry-run + * php management/events/migrate_movies_events_shape.php --apply # write + */ + +require_once sprintf('%s/../../vendor/autoload.php', __DIR__); +require_once sprintf('%s/../config.php', __DIR__); +require_once sprintf('%s/../../src/Database/DbConnection.php', __DIR__); +require_once sprintf('%s/events_shape.php', __DIR__); + +$apply = in_array('--apply', $argv, true); +echo $apply + ? "MODE: APPLY — writing changes to movies\n\n" + : "MODE: DRY-RUN — no writes; re-run with --apply to write\n\n"; + +$db = new Database_DbConnection(); +$res = $db->query("SELECT id, eventsState FROM movies"); + +$migrated = 0; +$skipped = 0; +$conversions = []; +while ($row = $res->fetch_assoc()) { + $tree = json_decode($row['eventsState'] ?? '', true); + if (!is_array($tree)) { + $tree = []; + } + + // Already new-shape — leave it alone (idempotency / resume). + if (isset($tree['event_selections'])) { + $skipped++; + continue; + } + + $shape = events_shape_from_tree($tree); + events_migration_collect($conversions, "#{$row['id']}", $tree, $shape); + + if ($apply) { + $new_blob = json_encode($shape); + $stmt = $db->link->prepare("UPDATE movies SET eventsState = ? WHERE id = ?"); + $stmt->bind_param('si', $new_blob, $row['id']); + $stmt->execute(); + $stmt->close(); + } + $migrated++; +} +$res->close(); + +events_migration_print_unique($conversions, 'movies'); + +$verb = $apply ? "migrated" : "would migrate"; +echo "movies: $verb $migrated, skipped $skipped (already new-shape)\n"; diff --git a/management/events/migrate_screenshots_events_shape.php b/management/events/migrate_screenshots_events_shape.php new file mode 100644 index 00000000..31810c52 --- /dev/null +++ b/management/events/migrate_screenshots_events_shape.php @@ -0,0 +1,67 @@ + event_selections & event_visibility_selections" for every + * row it would migrate, so the mapping can be validated before committing. + * - Idempotent + resumable: rows already carrying event_selections are + * skipped, so the script is safe to re-run and to interrupt. + * - The primary key (auto-inc id) is never touched. + * - Runs inside the maintenance window, after the new code is deployed. + * + * Retired in the post-deploy cleanup (with EventsStateManager). + * + * Usage: + * php management/events/migrate_screenshots_events_shape.php # dry-run + * php management/events/migrate_screenshots_events_shape.php --apply # write + */ + +require_once sprintf('%s/../../vendor/autoload.php', __DIR__); +require_once sprintf('%s/../config.php', __DIR__); +require_once sprintf('%s/../../src/Database/DbConnection.php', __DIR__); +require_once sprintf('%s/events_shape.php', __DIR__); + +$apply = in_array('--apply', $argv, true); +echo $apply + ? "MODE: APPLY — writing changes to screenshots\n\n" + : "MODE: DRY-RUN — no writes; re-run with --apply to write\n\n"; + +$db = new Database_DbConnection(); +$res = $db->query("SELECT id, eventsState FROM screenshots"); + +$migrated = 0; +$skipped = 0; +$conversions = []; +while ($row = $res->fetch_assoc()) { + $tree = json_decode($row['eventsState'] ?? '', true); + if (!is_array($tree)) { + $tree = []; + } + + // Already new-shape — leave it alone (idempotency / resume). + if (isset($tree['event_selections'])) { + $skipped++; + continue; + } + + $shape = events_shape_from_tree($tree); + events_migration_collect($conversions, "#{$row['id']}", $tree, $shape); + + if ($apply) { + $new_blob = json_encode($shape); + $stmt = $db->link->prepare("UPDATE screenshots SET eventsState = ? WHERE id = ?"); + $stmt->bind_param('si', $new_blob, $row['id']); + $stmt->execute(); + $stmt->close(); + } + $migrated++; +} +$res->close(); + +events_migration_print_unique($conversions, 'screenshots'); + +$verb = $apply ? "migrated" : "would migrate"; +echo "screenshots: $verb $migrated, skipped $skipped (already new-shape)\n"; From 0902a498053a96fde25cb4fc79627aa80a4f2a97 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 28 Jul 2026 05:28:38 +0000 Subject: [PATCH 16/31] make command for requeue movies --- Makefile | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 1e3c6036..d9ac043f 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ MIGRATE_EVENTS = $(DOCKER_EXEC) php management/events APPLY_FLAG = $(if $(APPLY),--apply,) .PHONY: help test event-tests regression-tests test-filter test-file shell exec restart-movies db-shell \ - migrate-events migrate-screenshots migrate-movies migrate-client-states + migrate-events migrate-screenshots migrate-movies migrate-client-states requeue-movie .DEFAULT_GOAL := help @@ -31,6 +31,8 @@ help: @echo " make migrate-screenshots - Only the screenshots table (dry-run; APPLY=1 to write)" @echo " make migrate-movies - Only the movies table (dry-run; APPLY=1 to write)" @echo " make migrate-client-states - Only the client_states table (dry-run; APPLY=1 to write)" + @echo " make requeue-movie id=... - Rebuild a movie by its public id (force=true)" + @echo " e.g. make requeue-movie id=hl66n" shell: $(DOCKER_EXEC) bash @@ -81,3 +83,10 @@ migrate-client-states: $(MIGRATE_EVENTS)/migrate_client_states_events_shape.php $(APPLY_FLAG) migrate-events: migrate-screenshots migrate-movies migrate-client-states + +# Rebuild a movie by its public id (e.g. hl66n). force=true so it re-queues even +# when the cached file already exists. Usage: make requeue-movie id=hl66n +requeue-movie: + @if [ -z "$(id)" ]; then echo "Error: pass id=, e.g. make requeue-movie id=hl66n"; exit 1; fi + $(DOCKER_EXEC) curl -s "http://localhost/?action=reQueueMovie&id=$(id)&force=true" + @echo From d76d85ef710bd1f180e8d908781bfcce800ef4f6 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 28 Jul 2026 05:29:27 +0000 Subject: [PATCH 17/31] increase eventsapi timeout and chunksizes --- settings/Config.Example.ini | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/settings/Config.Example.ini b/settings/Config.Example.ini index 4958a5af..a1d0ca90 100644 --- a/settings/Config.Example.ini +++ b/settings/Config.Example.ini @@ -88,13 +88,17 @@ import_events_auth = "" ; This is the URL of the eventsapi, you can set to your development one if needed events_api_url = "https://events.helioviewer.org" -; Timeout in seconds for Events API requests -events_api_timeout = 10 +; Timeout in seconds for Events API requests. Must comfortably exceed one +; chunk's time: at ~165ms/timestamp a 150-timestamp chunk is ~25s, and the +; events server's cold/first request can spike higher, so keep generous margin. +; If a chunk exceeds this, curl aborts (28) and EventContext::build swallows it +; into an empty context -> movie renders with no events. +events_api_timeout = 90 ; Number of timestamps per Events API batch request when building a movie. -; Smaller = faster individual requests, more requests total; larger = fewer -; round-trips but slower per request. 50 is a balanced default. -events_api_events_per_frame_chunksize = 50 +; Larger = fewer round-trips per movie, but each chunk must finish within +; events_api_timeout. Capped by events_api_events_per_frame_max_chunk_size below. +events_api_events_per_frame_chunksize = 150 ; Upstream-imposed cap on timestamps per Events API batch request. ; Any chunk size larger than this gets clamped down to this value. From b7b52e584084ca32f44cfe1f60d6eb68bcbd0268 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 31 Jul 2026 05:52:45 +0000 Subject: [PATCH 18/31] request events frames_with_selections with ?withDelta --- src/Event/Api/EventsApi.php | 5 ++++- .../api/GetEventsForFramesWithSelectionsTest.php | 14 +++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index 283bf3b0..6ed5d98f 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -217,7 +217,10 @@ public function getEventsForFramesWithSelections( $chunkSize = $maxChunk; } - $url = "/helioviewer/events/frames_with_selections"; + // ?withDelta asks the upstream to include the per-observation rotation + // deltas (dx, dy) in the 'timestamps' block, so EventContext can shift + // each event's marker + footprint to the frame's timestamp. + $url = "/helioviewer/events/frames_with_selections?withDelta"; $chunks = array_chunk($timestamps, $chunkSize); $fetchChunk = function (array $chunkTimestamps) use ($url, $selections) { diff --git a/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php b/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php index 40d06972..5bd2c48a 100644 --- a/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php +++ b/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php @@ -75,10 +75,10 @@ public function testItShouldFallBackToConfiguredChunkSizeWhenCallerPassesLessTha $this->mockClient->expects($this->exactly(2)) ->method('request') ->withConsecutive( - ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) use ($fallbackChunk) { + ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) use ($fallbackChunk) { return count($options['json']['timestamps']) === $fallbackChunk; })], - ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) use ($tail) { + ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) use ($tail) { return count($options['json']['timestamps']) === $tail; })] ) @@ -100,10 +100,10 @@ public function testItShouldCapChunkSizeAtUpstreamLimit(): void $this->mockClient->expects($this->exactly(2)) ->method('request') ->withConsecutive( - ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { + ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) { return count($options['json']['timestamps']) === 150; })], - ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { + ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) { return count($options['json']['timestamps']) === 50; })] ) @@ -126,15 +126,15 @@ public function testItShouldPaginateTimestampsAtTheGivenChunkSize(): void $this->mockClient->expects($this->exactly(3)) ->method('request') ->withConsecutive( - ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { + ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) { return count($options['json']['timestamps']) === 3 && $options['json']['selections'] === ['HEK>>Flare', 'CCMC>>DONKI>>CME']; })], - ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { + ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) { return count($options['json']['timestamps']) === 3 && $options['json']['selections'] === ['HEK>>Flare', 'CCMC>>DONKI>>CME']; })], - ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { + ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) { return count($options['json']['timestamps']) === 1 && $options['json']['selections'] === ['HEK>>Flare', 'CCMC>>DONKI>>CME']; })] From 5fa43957b3275a6d838484ce2966a0885f27a9d7 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 31 Jul 2026 05:54:27 +0000 Subject: [PATCH 19/31] update client_state test fixtures to new event shape --- .../unit_tests/module/test_data/invalid.json | 193 +++++----- tests/unit_tests/module/test_data/valid.json | 193 +++++----- .../valid/custom_event_layer_key.json | 189 +++++----- .../valid/minimal_event_group.json | 181 ++++----- .../test_data/client_state/valid/valid_1.json | 193 +++++----- .../client_state/valid/valid_10.json | 185 ++++------ .../client_state/valid/valid_11.json | 195 ++++------ .../client_state/valid/valid_12.json | 225 +++++------- .../client_state/valid/valid_13.json | 225 +++++------- .../client_state/valid/valid_14.json | 225 +++++------- .../client_state/valid/valid_15.json | 225 +++++------- .../client_state/valid/valid_16.json | 161 ++++---- .../client_state/valid/valid_17.json | 344 +++++------------- .../test_data/client_state/valid/valid_2.json | 203 +++++------ .../test_data/client_state/valid/valid_3.json | 223 ++++++------ .../test_data/client_state/valid/valid_4.json | 246 ++++++------- .../test_data/client_state/valid/valid_5.json | 250 ++++++------- .../test_data/client_state/valid/valid_6.json | 186 ++++------ .../test_data/client_state/valid/valid_7.json | 169 ++++----- .../test_data/client_state/valid/valid_8.json | 175 ++++----- .../test_data/client_state/valid/valid_9.json | 175 ++++----- .../valid/valid_event_layer_name.json | 177 ++++----- 22 files changed, 1951 insertions(+), 2587 deletions(-) diff --git a/tests/unit_tests/module/test_data/invalid.json b/tests/unit_tests/module/test_data/invalid.json index ab5cb167..a561a2eb 100644 --- a/tests/unit_tests/module/test_data/invalid.json +++ b/tests/unit_tests/module/test_data/invalid.json @@ -1,111 +1,104 @@ { - "date": 1712557812000, - "centerX": -172.3873528481749, - "centerY": "-216.25766746329495", - "imageScale": 0.60511022, - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "Observatory" + "date": 1712557812000, + "centerX": -172.3873528481749, + "centerY": "-216.25766746329495", + "imageScale": 0.60511022, + "event_selections": [], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "SJI", - "label": "Instrument" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "Measurement" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true + }, + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024/06/12 08:32:57", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": 100, - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ - { - "name": "SDO", - "label": "Observatory" - }, + "imageLayers": [ { - "name": "AIA", - "label": "Instrument" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "Observatory" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024/06/12 08:32:57", + "layeringOrder": 1 }, { - "name": "1600", - "label": "Measurement" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": 100, + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024/04/08 05:30:13", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024/04/08 05:30:13", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-venus-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-saturn-tree-label", - "soho-uranus-tree-label", - "soho-neptune-tree-label" - ], - "stereo_a": [ - "stereo_a-mercury-tree-label" ], - "stereo_b": [ - "stereo_b-mercury-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-venus-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-saturn-tree-label", + "soho-uranus-tree-label", + "soho-neptune-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label" + ] + } } diff --git a/tests/unit_tests/module/test_data/valid.json b/tests/unit_tests/module/test_data/valid.json index 90e3a350..78af2879 100644 --- a/tests/unit_tests/module/test_data/valid.json +++ b/tests/unit_tests/module/test_data/valid.json @@ -1,111 +1,104 @@ { - "date": 1712557812000, - "centerX": -172.3873528481749, - "centerY": -216.25766746329495, - "imageScale": 0.60511022, - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "Observatory" + "date": 1712557812000, + "centerX": -172.3873528481749, + "centerY": -216.25766746329495, + "imageScale": 0.60511022, + "event_selections": [], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "SJI", - "label": "Instrument" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "Measurement" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true + }, + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024-06-12T08:32:57.000Z", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": 100, - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ - { - "name": "SDO", - "label": "Observatory" - }, + "imageLayers": [ { - "name": "AIA", - "label": "Instrument" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "Observatory" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024-06-12T08:32:57.000Z", + "layeringOrder": 1 }, { - "name": "1600", - "label": "Measurement" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": 100, + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-04-08T05:30:13.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-04-08T05:30:13.000Z", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-venus-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-saturn-tree-label", - "soho-uranus-tree-label", - "soho-neptune-tree-label" - ], - "stereo_a": [ - "stereo_a-mercury-tree-label" ], - "stereo_b": [ - "stereo_b-mercury-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-venus-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-saturn-tree-label", + "soho-uranus-tree-label", + "soho-neptune-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/custom_event_layer_key.json b/tests/unit_tests/validation/test_data/client_state/valid/custom_event_layer_key.json index 3dd6b0cd..fa2728bb 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/custom_event_layer_key.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/custom_event_layer_key.json @@ -1,103 +1,108 @@ { - "date": 1712557812000, - "centerX": -172.3873528481749, - "centerY": -216.25766746329495, - "imageScale": 0.60511022, - "eventLayers": { - "allowed": { - "labels_visible": true, - "layers": [{ - "event_type": "AR", - "frms": ["some_frm"], - "event_instances": ["AR--something--ok"] - }] - }, - "tree_HEK": { - "labels_visible": true, - "layers": [{ - "event_type": "AR", - "frms": ["some_frm"], - "event_instances": ["AR--something--ok"] - }] - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "Observatory" + "date": 1712557812000, + "centerX": -172.3873528481749, + "centerY": -216.25766746329495, + "imageScale": 0.60511022, + "event_selections": [ + "HEK>>Active Region>>some_frm", + "HEK>>Active Region>>some frm", + "HEK>>Active Region>>something" + ], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "SJI", - "label": "Instrument" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "Measurement" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true + }, + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024-06-12T08:32:57.000Z", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": 100, - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ - { - "name": "SDO", - "label": "Observatory" - }, + "imageLayers": [ { - "name": "AIA", - "label": "Instrument" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "Observatory" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024-06-12T08:32:57.000Z", + "layeringOrder": 1 }, { - "name": "1600", - "label": "Measurement" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": 100, + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-04-08T05:30:13.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-04-08T05:30:13.000Z", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-venus-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-saturn-tree-label", - "soho-uranus-tree-label", - "soho-neptune-tree-label" - ], - "stereo_a": [ - "stereo_a-mercury-tree-label" ], - "stereo_b": [ - "stereo_b-mercury-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-venus-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-saturn-tree-label", + "soho-uranus-tree-label", + "soho-neptune-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/minimal_event_group.json b/tests/unit_tests/validation/test_data/client_state/valid/minimal_event_group.json index 02a2f72a..85a66b8a 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/minimal_event_group.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/minimal_event_group.json @@ -1,95 +1,108 @@ { - "date": 1712557812000, - "centerX": -172.3873528481749, - "centerY": -216.25766746329495, - "imageScale": 0.60511022, - "eventLayers": { - "tree_HEK": { - "labels_visible": true, - "layers": [{ - "event_type": "AR", - "frms": ["some_frm"], - "event_instances": ["AR--frm--encoded"] - }] - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "Observatory" + "date": 1712557812000, + "centerX": -172.3873528481749, + "centerY": -216.25766746329495, + "imageScale": 0.60511022, + "event_selections": [ + "HEK>>Active Region>>some_frm", + "HEK>>Active Region>>some frm", + "HEK>>Active Region>>frm" + ], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "SJI", - "label": "Instrument" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "Measurement" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true + }, + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024-06-12T08:32:57.000Z", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": 100, - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ + "imageLayers": [ { - "name": "SDO", - "label": "Observatory" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "Observatory" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024-06-12T08:32:57.000Z", + "layeringOrder": 1 }, { - "name": "AIA", - "label": "Instrument" - }, - { - "name": "1600", - "label": "Measurement" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": 100, + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-04-08T05:30:13.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-04-08T05:30:13.000Z", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-venus-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-saturn-tree-label", - "soho-uranus-tree-label", - "soho-neptune-tree-label" ], - "stereo_a": [ - "stereo_a-mercury-tree-label" - ], - "stereo_b": [ - "stereo_b-mercury-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-venus-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-saturn-tree-label", + "soho-uranus-tree-label", + "soho-neptune-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_1.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_1.json index 90e3a350..78af2879 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_1.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_1.json @@ -1,111 +1,104 @@ { - "date": 1712557812000, - "centerX": -172.3873528481749, - "centerY": -216.25766746329495, - "imageScale": 0.60511022, - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "Observatory" + "date": 1712557812000, + "centerX": -172.3873528481749, + "centerY": -216.25766746329495, + "imageScale": 0.60511022, + "event_selections": [], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "SJI", - "label": "Instrument" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "Measurement" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true + }, + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024-06-12T08:32:57.000Z", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": 100, - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ - { - "name": "SDO", - "label": "Observatory" - }, + "imageLayers": [ { - "name": "AIA", - "label": "Instrument" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "Observatory" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024-06-12T08:32:57.000Z", + "layeringOrder": 1 }, { - "name": "1600", - "label": "Measurement" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": 100, + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-04-08T05:30:13.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-04-08T05:30:13.000Z", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-venus-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-saturn-tree-label", - "soho-uranus-tree-label", - "soho-neptune-tree-label" - ], - "stereo_a": [ - "stereo_a-mercury-tree-label" ], - "stereo_b": [ - "stereo_b-mercury-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-venus-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-saturn-tree-label", + "soho-uranus-tree-label", + "soho-neptune-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_10.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_10.json index 2698e01a..021e1fa2 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_10.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_10.json @@ -1,120 +1,81 @@ { - "date": 1715731311000, - "centerX": -3018.5704096094664, - "centerY": -410.28075595668, - "imageScale": 38.72705408, - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [ - { - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": 1, - "event_type": "AR", - "event_instances": [] + "date": 1715731311000, + "centerX": -3018.5704096094664, + "centerY": -410.28075595668, + "imageScale": 38.72705408, + "event_selections": [ + "CCMC>>DONKI>>CME", + "RHESSI>>Solar Flares" + ], + "event_visibility_selections": { + "HEK": { + "marker_visibility": false, + "label_visibility": false }, - { - "frms": [ - "EGSO_SFC" - ], - "open": 1, - "event_type": "SS", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": false, - "markers_visible": false, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [ - { - "frms": [ - "CME" - ], - "open": 1, - "event_type": "C3", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [ - { - "frms": [ - "all" - ], - "open": 1, - "event_type": "F2", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": false, - "markers_visible": true, - "layer_available_visible": true - } - }, - "imageLayers": [ - { - "opacity": 100, - "visible": true, - "diffTime": 1, - "uiLabels": [ - { - "name": "SDO", - "label": "Observatory" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "AIA", - "label": "Instrument" + "RHESSI": { + "marker_visibility": true, + "label_visibility": false }, + "WSA": { + "marker_visibility": true, + "label_visibility": true + } + }, + "imageLayers": [ { - "name": "1600", - "label": "Measurement" + "opacity": 100, + "visible": true, + "diffTime": 1, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-06-17T10:49:02.000Z" } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-06-17T10:49:02.000Z" - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-uranus-tree-label" ], - "stereo_a": [ - "stereo_a-mercury-tree-label", - "stereo_a-venus-tree-label", - "stereo_a-earth-tree-label", - "stereo_a-mars-tree-label", - "stereo_a-jupiter-tree-label", - "stereo_a-saturn-tree-label", - "stereo_a-uranus-tree-label", - "stereo_a-neptune-tree-label", - "stereo_a-psp-tree-branch" - ], - "stereo_b": [ - "stereo_b-mercury-tree-label", - "stereo_b-earth-tree-label", - "stereo_b-mars-tree-label", - "stereo_b-jupiter-tree-label", - "stereo_b-saturn-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-uranus-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label", + "stereo_a-venus-tree-label", + "stereo_a-earth-tree-label", + "stereo_a-mars-tree-label", + "stereo_a-jupiter-tree-label", + "stereo_a-saturn-tree-label", + "stereo_a-uranus-tree-label", + "stereo_a-neptune-tree-label", + "stereo_a-psp-tree-branch" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label", + "stereo_b-earth-tree-label", + "stereo_b-mars-tree-label", + "stereo_b-jupiter-tree-label", + "stereo_b-saturn-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_11.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_11.json index 8c8142c2..021e1fa2 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_11.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_11.json @@ -1,130 +1,81 @@ { - "date": 1715731311000, - "centerX": -3018.5704096094664, - "centerY": -410.28075595668, - "imageScale": 38.72705408, - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [ - { - "frms": [], - "open": 1, - "event_type": "AR", - "event_instances": [ - "AR--NOAA_SWPC_Observer--aXZvOi8vaGVsaW8taW5mb3JtYXRpY3Mub3JnL0FSX05PQUFTV1BDT2JzZXJ2ZXJfMjAyNDA1MTVfMDI0ODIxX05PQUExMzY3MV8yMDI0MDUxNQ__", - "AR--NOAA_SWPC_Observer--aXZvOi8vaGVsaW8taW5mb3JtYXRpY3Mub3JnL0FSX05PQUFTV1BDT2JzZXJ2ZXJfMjAyNDA1MTVfMDI0ODIzX05PQUExMzY3Ml8yMDI0MDUxNQ__", - "AR--NOAA_SWPC_Observer--aXZvOi8vaGVsaW8taW5mb3JtYXRpY3Mub3JnL0FSX05PQUFTV1BDT2JzZXJ2ZXJfMjAyNDA1MTVfMDI0ODE5X05PQUExMzY3MF8yMDI0MDUxNQ__", - "AR--NOAA_SWPC_Observer--aXZvOi8vaGVsaW8taW5mb3JtYXRpY3Mub3JnL0FSX05PQUFTV1BDT2JzZXJ2ZXJfMjAyNDA1MTVfMDI0ODQwX05PQUExMzY4MF8yMDI0MDUxNQ__", - "AR--NOAA_SWPC_Observer--aXZvOi8vaGVsaW8taW5mb3JtYXRpY3Mub3JnL0FSX05PQUFTV1BDT2JzZXJ2ZXJfMjAyNDA1MTVfMDI0ODQzX05PQUExMzY4Ml8yMDI0MDUxNQ__", - "AR--NOAA_SWPC_Observer--aXZvOi8vaGVsaW8taW5mb3JtYXRpY3Mub3JnL0FSX05PQUFTV1BDT2JzZXJ2ZXJfMjAyNDA1MTVfMDI0ODM1X05PQUExMzY3OF8yMDI0MDUxNQ__", - "AR--NOAA_SWPC_Observer--aXZvOi8vaGVsaW8taW5mb3JtYXRpY3Mub3JnL0FSX05PQUFTV1BDT2JzZXJ2ZXJfMjAyNDA1MTVfMDI0ODI2X05PQUExMzY3M18yMDI0MDUxNQ__", - "AR--NOAA_SWPC_Observer--aXZvOi8vaGVsaW8taW5mb3JtYXRpY3Mub3JnL0FSX05PQUFTV1BDT2JzZXJ2ZXJfMjAyNDA1MTVfMDI0ODM4X05PQUExMzY3OV8yMDI0MDUxNQ__", - "AR--NOAA_SWPC_Observer--aXZvOi8vaGVsaW8taW5mb3JtYXRpY3Mub3JnL0FSX05PQUFTV1BDT2JzZXJ2ZXJfMjAyNDA1MTVfMDI0ODI4X05PQUExMzY3NF8yMDI0MDUxNQ__", - "AR--NOAA_SWPC_Observer--aXZvOi8vaGVsaW8taW5mb3JtYXRpY3Mub3JnL0FSX05PQUFTV1BDT2JzZXJ2ZXJfMjAyNDA1MTVfMDI0ODMwX05PQUExMzY3NV8yMDI0MDUxNQ__", - "AR--NOAA_SWPC_Observer--aXZvOi8vaGVsaW8taW5mb3JtYXRpY3Mub3JnL0FSX05PQUFTV1BDT2JzZXJ2ZXJfMjAyNDA1MTVfMDI0ODMzX05PQUExMzY3Nl8yMDI0MDUxNQ__" - ] + "date": 1715731311000, + "centerX": -3018.5704096094664, + "centerY": -410.28075595668, + "imageScale": 38.72705408, + "event_selections": [ + "CCMC>>DONKI>>CME", + "RHESSI>>Solar Flares" + ], + "event_visibility_selections": { + "HEK": { + "marker_visibility": false, + "label_visibility": false }, - { - "frms": [ - "EGSO_SFC" - ], - "open": 1, - "event_type": "SS", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": false, - "markers_visible": false, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [ - { - "frms": [ - "CME" - ], - "open": 1, - "event_type": "C3", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [ - { - "frms": [ - "all" - ], - "open": 1, - "event_type": "F2", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": false, - "markers_visible": true, - "layer_available_visible": true - } - }, - "imageLayers": [ - { - "opacity": 100, - "visible": true, - "diffTime": 1, - "uiLabels": [ - { - "name": "SDO", - "label": "Observatory" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "AIA", - "label": "Instrument" + "RHESSI": { + "marker_visibility": true, + "label_visibility": false }, + "WSA": { + "marker_visibility": true, + "label_visibility": true + } + }, + "imageLayers": [ { - "name": "1600", - "label": "Measurement" + "opacity": 100, + "visible": true, + "diffTime": 1, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-06-17T10:49:02.000Z" } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-06-17T10:49:02.000Z" - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-uranus-tree-label" ], - "stereo_a": [ - "stereo_a-mercury-tree-label", - "stereo_a-venus-tree-label", - "stereo_a-earth-tree-label", - "stereo_a-mars-tree-label", - "stereo_a-jupiter-tree-label", - "stereo_a-saturn-tree-label", - "stereo_a-uranus-tree-label", - "stereo_a-neptune-tree-label", - "stereo_a-psp-tree-branch" - ], - "stereo_b": [ - "stereo_b-mercury-tree-label", - "stereo_b-earth-tree-label", - "stereo_b-mars-tree-label", - "stereo_b-jupiter-tree-label", - "stereo_b-saturn-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-uranus-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label", + "stereo_a-venus-tree-label", + "stereo_a-earth-tree-label", + "stereo_a-mars-tree-label", + "stereo_a-jupiter-tree-label", + "stereo_a-saturn-tree-label", + "stereo_a-uranus-tree-label", + "stereo_a-neptune-tree-label", + "stereo_a-psp-tree-branch" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label", + "stereo_b-earth-tree-label", + "stereo_b-mars-tree-label", + "stereo_b-jupiter-tree-label", + "stereo_b-saturn-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_12.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_12.json index 9dbe23a2..78af2879 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_12.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_12.json @@ -1,145 +1,104 @@ { - "date": 1712557812000, - "centerX": -172.3873528481749, - "centerY": -216.25766746329495, - "imageScale": 0.60511022, - "eventLayers": { - "tree_CCMC": { - "id": "CCMC", - "labels_visible": true, - "layer_available_visible": true, - "layers": [ - { - "event_instances": [], - "event_type": "AR", - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": true + "date": 1712557812000, + "centerX": -172.3873528481749, + "centerY": -216.25766746329495, + "imageScale": 0.60511022, + "event_selections": [], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "event_instances": [], - "event_type": "FL", - "frms": [ - "SWPC" - ], - "open": true - } - ], - "markers_visible": true, - "visible": true - }, - "tree_HEK": { - "id": "HEK", - "labels_visible": true, - "layer_available_visible": true, - "layers": [], - "markers_visible": true, - "visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "labels_visible": true, - "layer_available_visible": true, - "layers": [ - { - "event_instances": [], - "event_type": "AR", - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": true - }, - { - "event_instances": [], - "event_type": "FL", - "frms": [ - "SWPC" - ], - "open": true - } - ], - "markers_visible": true, - "visible": true - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "Observatory" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "SJI", - "label": "Instrument" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "Measurement" + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024-06-12T08:32:57.000Z", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": 100, - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ + "imageLayers": [ { - "name": "SDO", - "label": "Observatory" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "Observatory" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024-06-12T08:32:57.000Z", + "layeringOrder": 1 }, { - "name": "AIA", - "label": "Instrument" - }, - { - "name": "1600", - "label": "Measurement" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": 100, + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-04-08T05:30:13.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-04-08T05:30:13.000Z", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-venus-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-saturn-tree-label", - "soho-uranus-tree-label", - "soho-neptune-tree-label" ], - "stereo_a": [ - "stereo_a-mercury-tree-label" - ], - "stereo_b": [ - "stereo_b-mercury-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-venus-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-saturn-tree-label", + "soho-uranus-tree-label", + "soho-neptune-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_13.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_13.json index 73f203d4..850bd314 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_13.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_13.json @@ -1,145 +1,104 @@ { - "date": 1712557812000, - "centerX": -172.3873528481749, - "centerY": -216.25766746329495, - "imageScale": 0.60511022, - "eventLayers": { - "tree_CCMC": { - "id": "CCMC", - "labels_visible": true, - "layer_available_visible": true, - "layers": [ - { - "event_instances": [], - "event_type": "AR", - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": true + "date": 1712557812000, + "centerX": -172.3873528481749, + "centerY": -216.25766746329495, + "imageScale": 0.60511022, + "event_selections": [], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "event_instances": [], - "event_type": "FL", - "frms": [ - "SWPC" - ], - "open": true - } - ], - "markers_visible": true, - "visible": true - }, - "tree_HEK": { - "id": "HEK", - "labels_visible": true, - "layer_available_visible": true, - "layers": [], - "markers_visible": true, - "visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "labels_visible": true, - "layer_available_visible": true, - "layers": [ - { - "event_instances": [], - "event_type": "AR", - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": true - }, - { - "event_instances": [], - "event_type": "FL", - "frms": [ - "SWPC" - ], - "open": true - } - ], - "markers_visible": true, - "visible": true - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "Observatory" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "SJI", - "label": "Instrument" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "Measurement" + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024-06-12T08:32:57.000Z", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": "100", - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ + "imageLayers": [ { - "name": "SDO", - "label": "Observatory" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "Observatory" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024-06-12T08:32:57.000Z", + "layeringOrder": 1 }, { - "name": "AIA", - "label": "Instrument" - }, - { - "name": "1600", - "label": "Measurement" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": "100", + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-04-08T05:30:13.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-04-08T05:30:13.000Z", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-venus-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-saturn-tree-label", - "soho-uranus-tree-label", - "soho-neptune-tree-label" ], - "stereo_a": [ - "stereo_a-mercury-tree-label" - ], - "stereo_b": [ - "stereo_b-mercury-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-venus-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-saturn-tree-label", + "soho-uranus-tree-label", + "soho-neptune-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_14.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_14.json index 7d541aa0..9a70be04 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_14.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_14.json @@ -1,145 +1,104 @@ { - "date": 1712557812000, - "centerX": -172.3873528481749, - "centerY": -216.25766746329495, - "imageScale": 0.60511022, - "eventLayers": { - "tree_CCMC": { - "id": "CCMC", - "labels_visible": true, - "layer_available_visible": true, - "layers": [ - { - "event_instances": [], - "event_type": "AR", - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": true + "date": 1712557812000, + "centerX": -172.3873528481749, + "centerY": -216.25766746329495, + "imageScale": 0.60511022, + "event_selections": [], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "event_instances": [], - "event_type": "FL", - "frms": [ - "SWPC" - ], - "open": true - } - ], - "markers_visible": true, - "visible": true - }, - "tree_HEK": { - "id": "HEK", - "labels_visible": true, - "layer_available_visible": true, - "layers": [], - "markers_visible": true, - "visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "labels_visible": true, - "layer_available_visible": true, - "layers": [ - { - "event_instances": [], - "event_type": "AR", - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": true - }, - { - "event_instances": [], - "event_type": "FL", - "frms": [ - "SWPC" - ], - "open": true - } - ], - "markers_visible": true, - "visible": true - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "SJI", - "label": "Instrument" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "" + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024-06-12T08:32:57.000Z", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": "100", - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ + "imageLayers": [ { - "name": "SDO", - "label": "" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024-06-12T08:32:57.000Z", + "layeringOrder": 1 }, { - "name": "AIA", - "label": "" - }, - { - "name": "1600", - "label": "" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": "100", + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "" + }, + { + "name": "AIA", + "label": "" + }, + { + "name": "1600", + "label": "" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-04-08T05:30:13.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-04-08T05:30:13.000Z", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-venus-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-saturn-tree-label", - "soho-uranus-tree-label", - "soho-neptune-tree-label" ], - "stereo_a": [ - "stereo_a-mercury-tree-label" - ], - "stereo_b": [ - "stereo_b-mercury-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-venus-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-saturn-tree-label", + "soho-uranus-tree-label", + "soho-neptune-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_15.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_15.json index 7d541aa0..9a70be04 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_15.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_15.json @@ -1,145 +1,104 @@ { - "date": 1712557812000, - "centerX": -172.3873528481749, - "centerY": -216.25766746329495, - "imageScale": 0.60511022, - "eventLayers": { - "tree_CCMC": { - "id": "CCMC", - "labels_visible": true, - "layer_available_visible": true, - "layers": [ - { - "event_instances": [], - "event_type": "AR", - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": true + "date": 1712557812000, + "centerX": -172.3873528481749, + "centerY": -216.25766746329495, + "imageScale": 0.60511022, + "event_selections": [], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "event_instances": [], - "event_type": "FL", - "frms": [ - "SWPC" - ], - "open": true - } - ], - "markers_visible": true, - "visible": true - }, - "tree_HEK": { - "id": "HEK", - "labels_visible": true, - "layer_available_visible": true, - "layers": [], - "markers_visible": true, - "visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "labels_visible": true, - "layer_available_visible": true, - "layers": [ - { - "event_instances": [], - "event_type": "AR", - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": true - }, - { - "event_instances": [], - "event_type": "FL", - "frms": [ - "SWPC" - ], - "open": true - } - ], - "markers_visible": true, - "visible": true - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "SJI", - "label": "Instrument" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "" + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024-06-12T08:32:57.000Z", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": "100", - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ + "imageLayers": [ { - "name": "SDO", - "label": "" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024-06-12T08:32:57.000Z", + "layeringOrder": 1 }, { - "name": "AIA", - "label": "" - }, - { - "name": "1600", - "label": "" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": "100", + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "" + }, + { + "name": "AIA", + "label": "" + }, + { + "name": "1600", + "label": "" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-04-08T05:30:13.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-04-08T05:30:13.000Z", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-venus-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-saturn-tree-label", - "soho-uranus-tree-label", - "soho-neptune-tree-label" ], - "stereo_a": [ - "stereo_a-mercury-tree-label" - ], - "stereo_b": [ - "stereo_b-mercury-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-venus-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-saturn-tree-label", + "soho-uranus-tree-label", + "soho-neptune-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_16.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_16.json index 87d11227..27ad82c2 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_16.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_16.json @@ -1,95 +1,82 @@ { - "date": 1697979187000, - "imageScale": 4.84088176, - "centerX": 100.29816701545585, - "centerY": 1064.2094221854543, - "imageLayers": [ - { - "uiLabels": [ + "date": 1697979187000, + "imageScale": 4.84088176, + "centerX": 100.29816701545585, + "centerY": 1064.2094221854543, + "imageLayers": [ { - "label": "Observatory", - "name": "SDO" + "uiLabels": [ + { + "label": "Observatory", + "name": "SDO" + }, + { + "label": "Instrument", + "name": "AIA" + }, + { + "label": "Measurement", + "name": "131" + } + ], + "Observatory": "SDO", + "Instrument": "AIA", + "Measurement": "131", + "visible": "1", + "opacity": 100, + "difference": 0, + "diffCount": 60, + "diffTime": 1, + "baseDiffTime": "2018-03-30T04:18:36.000Z", + "sourceId": 9, + "nickname": "AIA 131", + "layeringOrder": 1, + "start": "2010-06-02 00:05:35", + "end": "2024-07-22 15:48:07" + } + ], + "event_selections": [ + "HEK>>CME>>CACTus_\\(Computer_Aided_CME_Tracking\\)", + "HEK>>CME>>CACTus \\(Computer Aided CME Tracking\\)" + ], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "label": "Instrument", - "name": "AIA" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "label": "Measurement", - "name": "131" - } - ], - "Observatory": "SDO", - "Instrument": "AIA", - "Measurement": "131", - "visible": "1", - "opacity": 100, - "difference": 0, - "diffCount": 60, - "diffTime": 1, - "baseDiffTime": "2018-03-30T04:18:36.000Z", - "sourceId": 9, - "nickname": "AIA 131", - "layeringOrder": 1, - "start": "2010-06-02 00:05:35", - "end": "2024-07-22 15:48:07" - } - ], - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [ - { - "event_type": "CE", - "frms": [ - "CACTus_\\(Computer_Aided_CME_Tracking\\)" - ], - "event_instances": [], - "open": 1 + "RHESSI": { + "marker_visibility": true, + "label_visibility": true + }, + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false }, - "tree_CCMC": { - "id": "CCMC", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-venus-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-saturn-tree-label", + "soho-uranus-tree-label", + "soho-neptune-tree-label" + ], + "stereo_a": [], + "stereo_b": [ + "stereo_b-mercury-tree-label", + "stereo_b-venus-tree-label", + "stereo_b-earth-tree-label", + "stereo_b-mars-tree-label", + "stereo_b-jupiter-tree-label", + "stereo_b-saturn-tree-label", + "stereo_b-uranus-tree-label", + "stereo_b-neptune-tree-label" + ] } - }, - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-venus-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-saturn-tree-label", - "soho-uranus-tree-label", - "soho-neptune-tree-label" - ], - "stereo_a": [], - "stereo_b": [ - "stereo_b-mercury-tree-label", - "stereo_b-venus-tree-label", - "stereo_b-earth-tree-label", - "stereo_b-mars-tree-label", - "stereo_b-jupiter-tree-label", - "stereo_b-saturn-tree-label", - "stereo_b-uranus-tree-label", - "stereo_b-neptune-tree-label" - ] - } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_17.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_17.json index 6053161f..1afc866b 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_17.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_17.json @@ -1,259 +1,97 @@ { - "date": 1410269529000, - "imageScale": 4.84088176, - "centerX": -2894.0322215830834, - "centerY": 101.72638697583727, - "imageLayers": [ - { - "end": "2024-07-29 17:33:45", - "start": "2010-06-02 00:05:36", - "opacity": 100, - "visible": true, - "diffTime": 1, - "nickname": "AIA 171", - "sourceId": 10, - "uiLabels": [ - { - "label": "Observatory", - "name": "SDO" - }, - { - "label": "Instrument", - "name": "AIA" - }, - { - "label": "Measurement", - "name": "171" + "date": 1410269529000, + "imageScale": 4.84088176, + "centerX": -2894.0322215830834, + "centerY": 101.72638697583727, + "imageLayers": [ + { + "end": "2024-07-29 17:33:45", + "start": "2010-06-02 00:05:36", + "opacity": 100, + "visible": true, + "diffTime": 1, + "nickname": "AIA 171", + "sourceId": 10, + "uiLabels": [ + { + "label": "Observatory", + "name": "SDO" + }, + { + "label": "Instrument", + "name": "AIA" + }, + { + "label": "Measurement", + "name": "171" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "171", + "Observatory": "SDO", + "baseDiffTime": "2024-07-19T13:28:50.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "171", - "Observatory": "SDO", - "baseDiffTime": "2024-07-19T13:28:50.000Z", - "layeringOrder": 1 - } - ], - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [ - { - "event_type": "AR", - "frms": [ - "HMI_SHARP", - "NOAA_SWPC_Observer", - "SPoCA" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "CC", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "CD", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "CH", - "frms": [ - "SPoCA" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "CJ", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "CE", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "CR", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "CW", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "EF", - "frms": [ - "Emerging_flux_region_module" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "ER", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "FI", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "FA", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "FE", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "FL", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "LP", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "OS", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "PG", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "SG", - "frms": [ - "Sigmoid_Sniffer" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "SP", - "frms": [ - "all" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "SS", - "frms": [ - "EGSO_SFC" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "TO", - "frms": [ - "SSW_PFSS_v2_\\+_null_point_finder_v20180808" - ], - "event_instances": [], - "open": 1 - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [ - { - "event_type": "C3", - "frms": [ - "CME" - ], - "event_instances": [], - "open": 1 - }, - { - "event_type": "FP", - "frms": [ - "MAG4_LoS_FEr" - ], - "event_instances": [], - "open": 1 + ], + "event_selections": [ + "HEK>>Active Region>>HMI_SHARP", + "HEK>>Active Region>>HMI SHARP", + "HEK>>Active Region>>NOAA_SWPC_Observer", + "HEK>>Active Region>>NOAA SWPC Observer", + "HEK>>Active Region>>SPoCA", + "HEK>>Coronal Cavity", + "HEK>>Coronal Dimming", + "HEK>>Coronal Hole>>SPoCA", + "HEK>>Coronal Jet", + "HEK>>CME", + "HEK>>Coronal Rain", + "HEK>>Coronal Wave", + "HEK>>Emerging Flux>>Emerging_flux_region_module", + "HEK>>Emerging Flux>>Emerging flux region module", + "HEK>>Eruption", + "HEK>>Filament", + "HEK>>Filament Activation", + "HEK>>Filament Eruption", + "HEK>>Flare", + "HEK>>Loop", + "HEK>>Oscillation", + "HEK>>Plage", + "HEK>>Sigmoid>>Sigmoid_Sniffer", + "HEK>>Sigmoid>>Sigmoid Sniffer", + "HEK>>Spray Surge", + "HEK>>Sunspot>>EGSO_SFC", + "HEK>>Sunspot>>EGSO SFC", + "HEK>>Topological Object>>SSW_PFSS_v2_\\+_null_point_finder_v20180808", + "HEK>>Topological Object>>SSW PFSS v2 \\+ null point finder v20180808", + "CCMC>>DONKI>>CME", + "CCMC>>Solar Flare Predictions>>MAG4_LoS_FEr", + "CCMC>>Solar Flare Predictions>>MAG4 LoS FEr" + ], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true + }, + "CCMC": { + "marker_visibility": true, + "label_visibility": true + }, + "RHESSI": { + "marker_visibility": true, + "label_visibility": true + }, + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true + "celestialBodies": { + "soho": [], + "stereo_a": [ + "stereo_a-mercury-tree-label" + ], + "stereo_b": [] } - }, - "celestialBodies": { - "soho": [], - "stereo_a": [ - "stereo_a-mercury-tree-label" - ], - "stereo_b": [] - } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_2.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_2.json index cf0ce803..76fc152f 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_2.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_2.json @@ -1,116 +1,109 @@ { - "date": 1718087894000, - "centerX": -2401.07735296, - "centerY": -1316.71983872, - "imageScale": 77.45410816, - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "Observatory" + "date": 1718087894000, + "centerX": -2401.07735296, + "centerY": -1316.71983872, + "imageScale": 77.45410816, + "event_selections": [], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "SJI", - "label": "Instrument" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "Measurement" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true + }, + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024-06-12T08:32:57.000Z", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": 100, - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ - { - "name": "SDO", - "label": "Observatory" - }, + "imageLayers": [ { - "name": "AIA", - "label": "Instrument" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "Observatory" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024-06-12T08:32:57.000Z", + "layeringOrder": 1 }, { - "name": "1600", - "label": "Measurement" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": 100, + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-04-08T05:30:13.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-04-08T05:30:13.000Z", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-saturn-tree-label", - "soho-uranus-tree-label", - "soho-neptune-tree-label" - ], - "stereo_a": [ - "stereo_a-mercury-tree-label", - "stereo_a-earth-tree-label", - "stereo_a-mars-tree-label", - "stereo_a-jupiter-tree-label", - "stereo_a-saturn-tree-label", - "stereo_a-uranus-tree-label", - "stereo_a-psp-tree-branch" ], - "stereo_b": [ - "stereo_b-mercury-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-saturn-tree-label", + "soho-uranus-tree-label", + "soho-neptune-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label", + "stereo_a-earth-tree-label", + "stereo_a-mars-tree-label", + "stereo_a-jupiter-tree-label", + "stereo_a-saturn-tree-label", + "stereo_a-uranus-tree-label", + "stereo_a-psp-tree-branch" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_3.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_3.json index 56bb5a6b..7a0b4d62 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_3.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_3.json @@ -1,133 +1,114 @@ { - "date": 1718087894000, - "centerX": -2401.07735296, - "centerY": -1316.71983872, - "imageScale": 77.45410816, - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [ - { - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": 1, - "event_type": "AR", - "event_instances": [] + "date": 1718087894000, + "centerX": -2401.07735296, + "centerY": -1316.71983872, + "imageScale": 77.45410816, + "event_selections": [ + "HEK>>Active Region>>NOAA_SWPC_Observer", + "HEK>>Active Region>>NOAA SWPC Observer", + "HEK>>Sunspot>>EGSO_SFC", + "HEK>>Sunspot>>EGSO SFC" + ], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "frms": [ - "EGSO_SFC" - ], - "open": 1, - "event_type": "SS", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "Observatory" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "SJI", - "label": "Instrument" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "Measurement" + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024-06-12T08:32:57.000Z", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": 100, - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ - { - "name": "SDO", - "label": "Observatory" - }, + "imageLayers": [ { - "name": "AIA", - "label": "Instrument" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "Observatory" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024-06-12T08:32:57.000Z", + "layeringOrder": 1 }, { - "name": "1600", - "label": "Measurement" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": 100, + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-04-08T05:30:13.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-04-08T05:30:13.000Z", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-saturn-tree-label", - "soho-uranus-tree-label", - "soho-neptune-tree-label" - ], - "stereo_a": [ - "stereo_a-mercury-tree-label", - "stereo_a-earth-tree-label", - "stereo_a-mars-tree-label", - "stereo_a-jupiter-tree-label", - "stereo_a-saturn-tree-label", - "stereo_a-uranus-tree-label", - "stereo_a-psp-tree-branch" ], - "stereo_b": [ - "stereo_b-mercury-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-saturn-tree-label", + "soho-uranus-tree-label", + "soho-neptune-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label", + "stereo_a-earth-tree-label", + "stereo_a-mars-tree-label", + "stereo_a-jupiter-tree-label", + "stereo_a-saturn-tree-label", + "stereo_a-uranus-tree-label", + "stereo_a-psp-tree-branch" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_4.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_4.json index af6718e5..ec6a40df 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_4.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_4.json @@ -1,153 +1,117 @@ { - "date": 1718087894000, - "centerX": 3950.15951616, - "centerY": 4724.70059776, - "imageScale": 77.45410816, - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [ - { - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": 1, - "event_type": "AR", - "event_instances": [] + "date": 1718087894000, + "centerX": 3950.15951616, + "centerY": 4724.70059776, + "imageScale": 77.45410816, + "event_selections": [ + "HEK>>Active Region>>NOAA_SWPC_Observer", + "HEK>>Active Region>>NOAA SWPC Observer", + "HEK>>Sunspot>>EGSO_SFC", + "HEK>>Sunspot>>EGSO SFC", + "CCMC>>DONKI>>CME", + "CCMC>>Solar Flare Predictions>>ASAP" + ], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "frms": [ - "EGSO_SFC" - ], - "open": 1, - "event_type": "SS", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [ - { - "frms": [ - "CME" - ], - "open": 1, - "event_type": "C3", - "event_instances": [] + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "frms": [], - "open": 1, - "event_type": "FP", - "event_instances": [ - "FP--ASAP--MWFhNTE2ZWQ5YWY0NWExNzJiZDM2ZGQzZDVkMDYyODgwZTNmYzIyNDQ1MmE5Zjg0ZTUzY2I0MTZjN2VkZDc0NA__", - "FP--ASAP--NWNmODQ3NzdhMGE1NTg5NGIyMjhjMzliNmQxMzk3M2U5OGQ0MjY1MDMwMTViZGEzMDU3NTkxMjU1NDBmZGUwNA__", - "FP--ASAP--MTFmMThkYmZjNzFlYTBmNDAzNjYyYTEzNTFhNzE4ODIyYTNlZWMwY2FlZTBkYjk0ZTg4MGYxMjcwNjEyZDBjYw__" - ] - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "Observatory" - }, - { - "name": "SJI", - "label": "Instrument" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "Measurement" + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024-06-12T08:32:57.000Z", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": 100, - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ + "imageLayers": [ { - "name": "SDO", - "label": "Observatory" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "Observatory" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024-06-12T08:32:57.000Z", + "layeringOrder": 1 }, { - "name": "AIA", - "label": "Instrument" - }, - { - "name": "1600", - "label": "Measurement" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": 100, + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-04-08T05:30:13.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-04-08T05:30:13.000Z", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mars-tree-label", - "soho-jupiter-tree-label" ], - "stereo_a": [ - "stereo_a-mercury-tree-label", - "stereo_a-venus-tree-label", - "stereo_a-earth-tree-label", - "stereo_a-mars-tree-label", - "stereo_a-jupiter-tree-label", - "stereo_a-saturn-tree-label", - "stereo_a-uranus-tree-label", - "stereo_a-neptune-tree-label", - "stereo_a-psp-tree-branch" - ], - "stereo_b": [ - "stereo_b-mercury-tree-label", - "stereo_b-mars-tree-label", - "stereo_b-jupiter-tree-label", - "stereo_b-saturn-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mars-tree-label", + "soho-jupiter-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label", + "stereo_a-venus-tree-label", + "stereo_a-earth-tree-label", + "stereo_a-mars-tree-label", + "stereo_a-jupiter-tree-label", + "stereo_a-saturn-tree-label", + "stereo_a-uranus-tree-label", + "stereo_a-neptune-tree-label", + "stereo_a-psp-tree-branch" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label", + "stereo_b-mars-tree-label", + "stereo_b-jupiter-tree-label", + "stereo_b-saturn-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_5.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_5.json index a78d21d2..d3c39801 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_5.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_5.json @@ -1,155 +1,119 @@ { - "date": 1718087894000, - "centerX": 1471.6280550400002, - "centerY": 2168.71502848, - "imageScale": 77.45410816, - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [ - { - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": 1, - "event_type": "AR", - "event_instances": [] + "date": 1718087894000, + "centerX": 1471.6280550400002, + "centerY": 2168.71502848, + "imageScale": 77.45410816, + "event_selections": [ + "HEK>>Active Region>>NOAA_SWPC_Observer", + "HEK>>Active Region>>NOAA SWPC Observer", + "HEK>>Sunspot>>EGSO_SFC", + "HEK>>Sunspot>>EGSO SFC", + "CCMC>>DONKI>>CME", + "CCMC>>Solar Flare Predictions>>ASAP" + ], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "frms": [ - "EGSO_SFC" - ], - "open": 1, - "event_type": "SS", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [ - { - "frms": [ - "CME" - ], - "open": 1, - "event_type": "C3", - "event_instances": [] + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "frms": [], - "open": 1, - "event_type": "FP", - "event_instances": [ - "FP--ASAP--MWFhNTE2ZWQ5YWY0NWExNzJiZDM2ZGQzZDVkMDYyODgwZTNmYzIyNDQ1MmE5Zjg0ZTUzY2I0MTZjN2VkZDc0NA__", - "FP--ASAP--NWNmODQ3NzdhMGE1NTg5NGIyMjhjMzliNmQxMzk3M2U5OGQ0MjY1MDMwMTViZGEzMDU3NTkxMjU1NDBmZGUwNA__", - "FP--ASAP--MTFmMThkYmZjNzFlYTBmNDAzNjYyYTEzNTFhNzE4ODIyYTNlZWMwY2FlZTBkYjk0ZTg4MGYxMjcwNjEyZDBjYw__" - ] - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "Observatory" - }, - { - "name": "SJI", - "label": "Instrument" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "Measurement" + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024-06-12T08:32:57.000Z", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": 100, - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ + "imageLayers": [ { - "name": "SDO", - "label": "Observatory" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "Observatory" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024-06-12T08:32:57.000Z", + "layeringOrder": 1 }, { - "name": "AIA", - "label": "Instrument" - }, - { - "name": "1600", - "label": "Measurement" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": 100, + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-04-08T05:30:13.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-04-08T05:30:13.000Z", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-uranus-tree-label" ], - "stereo_a": [ - "stereo_a-mercury-tree-label", - "stereo_a-venus-tree-label", - "stereo_a-earth-tree-label", - "stereo_a-mars-tree-label", - "stereo_a-jupiter-tree-label", - "stereo_a-saturn-tree-label", - "stereo_a-uranus-tree-label", - "stereo_a-neptune-tree-label", - "stereo_a-psp-tree-branch" - ], - "stereo_b": [ - "stereo_b-mercury-tree-label", - "stereo_b-mars-tree-label", - "stereo_b-jupiter-tree-label", - "stereo_b-saturn-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-uranus-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label", + "stereo_a-venus-tree-label", + "stereo_a-earth-tree-label", + "stereo_a-mars-tree-label", + "stereo_a-jupiter-tree-label", + "stereo_a-saturn-tree-label", + "stereo_a-uranus-tree-label", + "stereo_a-neptune-tree-label", + "stereo_a-psp-tree-branch" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label", + "stereo_b-mars-tree-label", + "stereo_b-jupiter-tree-label", + "stereo_b-saturn-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_6.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_6.json index 758da4a3..bde9b97f 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_6.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_6.json @@ -1,120 +1,84 @@ { - "date": 1718087894000, - "centerX": 1471.6280550400002, - "centerY": 2168.71502848, - "imageScale": 77.45410816, - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [ - { - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": 1, - "event_type": "AR", - "event_instances": [] + "date": 1718087894000, + "centerX": 1471.6280550400002, + "centerY": 2168.71502848, + "imageScale": 77.45410816, + "event_selections": [ + "HEK>>Active Region>>NOAA_SWPC_Observer", + "HEK>>Active Region>>NOAA SWPC Observer", + "HEK>>Sunspot>>EGSO_SFC", + "HEK>>Sunspot>>EGSO SFC", + "CCMC>>DONKI>>CME", + "CCMC>>Solar Flare Predictions>>ASAP" + ], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "frms": [ - "EGSO_SFC" - ], - "open": 1, - "event_type": "SS", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [ - { - "frms": [ - "CME" - ], - "open": 1, - "event_type": "C3", - "event_instances": [] + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "frms": [], - "open": 1, - "event_type": "FP", - "event_instances": [ - "FP--ASAP--MWFhNTE2ZWQ5YWY0NWExNzJiZDM2ZGQzZDVkMDYyODgwZTNmYzIyNDQ1MmE5Zjg0ZTUzY2I0MTZjN2VkZDc0NA__", - "FP--ASAP--NWNmODQ3NzdhMGE1NTg5NGIyMjhjMzliNmQxMzk3M2U5OGQ0MjY1MDMwMTViZGEzMDU3NTkxMjU1NDBmZGUwNA__", - "FP--ASAP--MTFmMThkYmZjNzFlYTBmNDAzNjYyYTEzNTFhNzE4ODIyYTNlZWMwY2FlZTBkYjk0ZTg4MGYxMjcwNjEyZDBjYw__" - ] + "RHESSI": { + "marker_visibility": true, + "label_visibility": true + }, + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - } - }, - "imageLayers": [ - { - "opacity": 100, - "visible": true, - "diffTime": 1, - "uiLabels": [ - { - "name": "SDO", - "label": "Observatory" - }, - { - "name": "AIA", - "label": "Instrument" - }, + "imageLayers": [ { - "name": "1600", - "label": "Measurement" + "opacity": 100, + "visible": true, + "diffTime": 1, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-06-17T10:49:02.000Z" } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-06-17T10:49:02.000Z" - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-uranus-tree-label" ], - "stereo_a": [ - "stereo_a-mercury-tree-label", - "stereo_a-venus-tree-label", - "stereo_a-earth-tree-label", - "stereo_a-mars-tree-label", - "stereo_a-jupiter-tree-label", - "stereo_a-saturn-tree-label", - "stereo_a-uranus-tree-label", - "stereo_a-neptune-tree-label", - "stereo_a-psp-tree-branch" - ], - "stereo_b": [ - "stereo_b-mercury-tree-label", - "stereo_b-mars-tree-label", - "stereo_b-jupiter-tree-label", - "stereo_b-saturn-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-uranus-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label", + "stereo_a-venus-tree-label", + "stereo_a-earth-tree-label", + "stereo_a-mars-tree-label", + "stereo_a-jupiter-tree-label", + "stereo_a-saturn-tree-label", + "stereo_a-uranus-tree-label", + "stereo_a-neptune-tree-label", + "stereo_a-psp-tree-branch" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label", + "stereo_b-mars-tree-label", + "stereo_b-jupiter-tree-label", + "stereo_b-saturn-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_7.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_7.json index d5a1fa0c..81db2c5d 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_7.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_7.json @@ -1,102 +1,83 @@ { - "date": 1715731311000, - "centerX": 1471.6280550400002, - "centerY": 2168.71502848, - "imageScale": 77.45410816, - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [ - { - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": 1, - "event_type": "AR", - "event_instances": [] + "date": 1715731311000, + "centerX": 1471.6280550400002, + "centerY": 2168.71502848, + "imageScale": 77.45410816, + "event_selections": [ + "HEK>>Active Region>>NOAA_SWPC_Observer", + "HEK>>Active Region>>NOAA SWPC Observer", + "HEK>>Sunspot>>EGSO_SFC", + "HEK>>Sunspot>>EGSO SFC" + ], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "frms": [ - "EGSO_SFC" - ], - "open": 1, - "event_type": "SS", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - } - }, - "imageLayers": [ - { - "opacity": 100, - "visible": true, - "diffTime": 1, - "uiLabels": [ - { - "name": "SDO", - "label": "Observatory" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "AIA", - "label": "Instrument" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true }, + "WSA": { + "marker_visibility": true, + "label_visibility": true + } + }, + "imageLayers": [ { - "name": "1600", - "label": "Measurement" + "opacity": 100, + "visible": true, + "diffTime": 1, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-06-17T10:49:02.000Z" } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-06-17T10:49:02.000Z" - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-uranus-tree-label" - ], - "stereo_a": [ - "stereo_a-mercury-tree-label", - "stereo_a-venus-tree-label", - "stereo_a-earth-tree-label", - "stereo_a-mars-tree-label", - "stereo_a-jupiter-tree-label", - "stereo_a-saturn-tree-label", - "stereo_a-uranus-tree-label", - "stereo_a-neptune-tree-label", - "stereo_a-psp-tree-branch" ], - "stereo_b": [ - "stereo_b-mercury-tree-label", - "stereo_b-earth-tree-label", - "stereo_b-mars-tree-label", - "stereo_b-jupiter-tree-label", - "stereo_b-saturn-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-uranus-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label", + "stereo_a-venus-tree-label", + "stereo_a-earth-tree-label", + "stereo_a-mars-tree-label", + "stereo_a-jupiter-tree-label", + "stereo_a-saturn-tree-label", + "stereo_a-uranus-tree-label", + "stereo_a-neptune-tree-label", + "stereo_a-psp-tree-branch" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label", + "stereo_b-earth-tree-label", + "stereo_b-mars-tree-label", + "stereo_b-jupiter-tree-label", + "stereo_b-saturn-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_8.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_8.json index 291fbacd..b9d3d47a 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_8.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_8.json @@ -1,111 +1,80 @@ { - "date": 1715731311000, - "centerX": -3018.5704096094664, - "centerY": -410.28075595668, - "imageScale": 38.72705408, - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [ - { - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": 1, - "event_type": "AR", - "event_instances": [] + "date": 1715731311000, + "centerX": -3018.5704096094664, + "centerY": -410.28075595668, + "imageScale": 38.72705408, + "event_selections": [ + "CCMC>>DONKI>>CME" + ], + "event_visibility_selections": { + "HEK": { + "marker_visibility": false, + "label_visibility": false }, - { - "frms": [ - "EGSO_SFC" - ], - "open": 1, - "event_type": "SS", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": false, - "markers_visible": false, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [ - { - "frms": [ - "CME" - ], - "open": 1, - "event_type": "C3", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": true - } - }, - "imageLayers": [ - { - "opacity": 100, - "visible": true, - "diffTime": 1, - "uiLabels": [ - { - "name": "SDO", - "label": "Observatory" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "AIA", - "label": "Instrument" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true }, + "WSA": { + "marker_visibility": true, + "label_visibility": true + } + }, + "imageLayers": [ { - "name": "1600", - "label": "Measurement" + "opacity": 100, + "visible": true, + "diffTime": 1, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-06-17T10:49:02.000Z" } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-06-17T10:49:02.000Z" - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-uranus-tree-label" ], - "stereo_a": [ - "stereo_a-mercury-tree-label", - "stereo_a-venus-tree-label", - "stereo_a-earth-tree-label", - "stereo_a-mars-tree-label", - "stereo_a-jupiter-tree-label", - "stereo_a-saturn-tree-label", - "stereo_a-uranus-tree-label", - "stereo_a-neptune-tree-label", - "stereo_a-psp-tree-branch" - ], - "stereo_b": [ - "stereo_b-mercury-tree-label", - "stereo_b-earth-tree-label", - "stereo_b-mars-tree-label", - "stereo_b-jupiter-tree-label", - "stereo_b-saturn-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-uranus-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label", + "stereo_a-venus-tree-label", + "stereo_a-earth-tree-label", + "stereo_a-mars-tree-label", + "stereo_a-jupiter-tree-label", + "stereo_a-saturn-tree-label", + "stereo_a-uranus-tree-label", + "stereo_a-neptune-tree-label", + "stereo_a-psp-tree-branch" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label", + "stereo_b-earth-tree-label", + "stereo_b-mars-tree-label", + "stereo_b-jupiter-tree-label", + "stereo_b-saturn-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_9.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_9.json index 52c9d7cc..b1654251 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_9.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_9.json @@ -1,111 +1,80 @@ { - "date": 1715731311000, - "centerX": -3018.5704096094664, - "centerY": -410.28075595668, - "imageScale": 38.72705408, - "eventLayers": { - "tree_HEK": { - "id": "HEK", - "layers": [ - { - "frms": [ - "NOAA_SWPC_Observer" - ], - "open": 1, - "event_type": "AR", - "event_instances": [] + "date": 1715731311000, + "centerX": -3018.5704096094664, + "centerY": -410.28075595668, + "imageScale": 38.72705408, + "event_selections": [ + "CCMC>>DONKI>>CME" + ], + "event_visibility_selections": { + "HEK": { + "marker_visibility": false, + "label_visibility": false }, - { - "frms": [ - "EGSO_SFC" - ], - "open": 1, - "event_type": "SS", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": false, - "markers_visible": false, - "layer_available_visible": false - }, - "tree_CCMC": { - "id": "CCMC", - "layers": [ - { - "frms": [ - "CME" - ], - "open": 1, - "event_type": "C3", - "event_instances": [] - } - ], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - }, - "tree_RHESSI": { - "id": "RHESSI", - "layers": [], - "visible": true, - "labels_visible": false, - "markers_visible": true, - "layer_available_visible": true - } - }, - "imageLayers": [ - { - "opacity": 100, - "visible": true, - "diffTime": 1, - "uiLabels": [ - { - "name": "SDO", - "label": "Observatory" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "AIA", - "label": "Instrument" + "RHESSI": { + "marker_visibility": true, + "label_visibility": false }, + "WSA": { + "marker_visibility": true, + "label_visibility": true + } + }, + "imageLayers": [ { - "name": "1600", - "label": "Measurement" + "opacity": 100, + "visible": true, + "diffTime": 1, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-06-17T10:49:02.000Z" } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-06-17T10:49:02.000Z" - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-uranus-tree-label" ], - "stereo_a": [ - "stereo_a-mercury-tree-label", - "stereo_a-venus-tree-label", - "stereo_a-earth-tree-label", - "stereo_a-mars-tree-label", - "stereo_a-jupiter-tree-label", - "stereo_a-saturn-tree-label", - "stereo_a-uranus-tree-label", - "stereo_a-neptune-tree-label", - "stereo_a-psp-tree-branch" - ], - "stereo_b": [ - "stereo_b-mercury-tree-label", - "stereo_b-earth-tree-label", - "stereo_b-mars-tree-label", - "stereo_b-jupiter-tree-label", - "stereo_b-saturn-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-uranus-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label", + "stereo_a-venus-tree-label", + "stereo_a-earth-tree-label", + "stereo_a-mars-tree-label", + "stereo_a-jupiter-tree-label", + "stereo_a-saturn-tree-label", + "stereo_a-uranus-tree-label", + "stereo_a-neptune-tree-label", + "stereo_a-psp-tree-branch" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label", + "stereo_b-earth-tree-label", + "stereo_b-mars-tree-label", + "stereo_b-jupiter-tree-label", + "stereo_b-saturn-tree-label" + ] + } } diff --git a/tests/unit_tests/validation/test_data/client_state/valid/valid_event_layer_name.json b/tests/unit_tests/validation/test_data/client_state/valid/valid_event_layer_name.json index ce07b291..78af2879 100644 --- a/tests/unit_tests/validation/test_data/client_state/valid/valid_event_layer_name.json +++ b/tests/unit_tests/validation/test_data/client_state/valid/valid_event_layer_name.json @@ -1,95 +1,104 @@ { - "date": 1712557812000, - "centerX": -172.3873528481749, - "centerY": -216.25766746329495, - "imageScale": 0.60511022, - "eventLayers": { - "allowed": { - "id": "HEK", - "layers": [], - "visible": true, - "labels_visible": true, - "markers_visible": true, - "layer_available_visible": false - } - }, - "imageLayers": [ - { - "end": "2024-04-08 06:30:13", - "start": "2024-04-08 06:19:24", - "opacity": 35, - "visible": true, - "diffTime": 1, - "nickname": "IRIS SJI 2796", - "sourceId": 89, - "uiLabels": [ - { - "name": "IRIS", - "label": "Observatory" + "date": 1712557812000, + "centerX": -172.3873528481749, + "centerY": -216.25766746329495, + "imageScale": 0.60511022, + "event_selections": [], + "event_visibility_selections": { + "HEK": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "SJI", - "label": "Instrument" + "CCMC": { + "marker_visibility": true, + "label_visibility": true }, - { - "name": "2796", - "label": "Measurement" + "RHESSI": { + "marker_visibility": true, + "label_visibility": true + }, + "WSA": { + "marker_visibility": true, + "label_visibility": true } - ], - "diffCount": 60, - "Instrument": "SJI", - "difference": 0, - "Measurement": "2796", - "Observatory": "IRIS", - "baseDiffTime": "2024-06-12T08:32:57.000Z", - "layeringOrder": 1 }, - { - "end": "2024-06-11 06:38:14", - "start": "2012-07-05 00:09:05", - "opacity": 100, - "visible": "1", - "diffTime": 1, - "nickname": "AIA 1600", - "sourceId": 15, - "uiLabels": [ + "imageLayers": [ { - "name": "SDO", - "label": "Observatory" + "end": "2024-04-08 06:30:13", + "start": "2024-04-08 06:19:24", + "opacity": 35, + "visible": true, + "diffTime": 1, + "nickname": "IRIS SJI 2796", + "sourceId": 89, + "uiLabels": [ + { + "name": "IRIS", + "label": "Observatory" + }, + { + "name": "SJI", + "label": "Instrument" + }, + { + "name": "2796", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "SJI", + "difference": 0, + "Measurement": "2796", + "Observatory": "IRIS", + "baseDiffTime": "2024-06-12T08:32:57.000Z", + "layeringOrder": 1 }, { - "name": "AIA", - "label": "Instrument" - }, - { - "name": "1600", - "label": "Measurement" + "end": "2024-06-11 06:38:14", + "start": "2012-07-05 00:09:05", + "opacity": 100, + "visible": "1", + "diffTime": 1, + "nickname": "AIA 1600", + "sourceId": 15, + "uiLabels": [ + { + "name": "SDO", + "label": "Observatory" + }, + { + "name": "AIA", + "label": "Instrument" + }, + { + "name": "1600", + "label": "Measurement" + } + ], + "diffCount": 60, + "Instrument": "AIA", + "difference": 0, + "Measurement": "1600", + "Observatory": "SDO", + "baseDiffTime": "2024-04-08T05:30:13.000Z", + "layeringOrder": 1 } - ], - "diffCount": 60, - "Instrument": "AIA", - "difference": 0, - "Measurement": "1600", - "Observatory": "SDO", - "baseDiffTime": "2024-04-08T05:30:13.000Z", - "layeringOrder": 1 - } - ], - "celestialBodies": { - "soho": [ - "soho-mercury-tree-label", - "soho-venus-tree-label", - "soho-mars-tree-label", - "soho-jupiter-tree-label", - "soho-saturn-tree-label", - "soho-uranus-tree-label", - "soho-neptune-tree-label" ], - "stereo_a": [ - "stereo_a-mercury-tree-label" - ], - "stereo_b": [ - "stereo_b-mercury-tree-label" - ] - } + "celestialBodies": { + "soho": [ + "soho-mercury-tree-label", + "soho-venus-tree-label", + "soho-mars-tree-label", + "soho-jupiter-tree-label", + "soho-saturn-tree-label", + "soho-uranus-tree-label", + "soho-neptune-tree-label" + ], + "stereo_a": [ + "stereo_a-mercury-tree-label" + ], + "stereo_b": [ + "stereo_b-mercury-tree-label" + ] + } } From 3da2527e7b6f891ac4ed1f9e465838cc664956ad Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 31 Jul 2026 16:53:52 +0000 Subject: [PATCH 20/31] yaml docs: adopt event_selections shape in takeScreenshot/postScreenshot/postMovie --- docs/src/openapi.yaml | 85 ++++++++++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 21 deletions(-) diff --git a/docs/src/openapi.yaml b/docs/src/openapi.yaml index e887c8be..0a85be33 100644 --- a/docs/src/openapi.yaml +++ b/docs/src/openapi.yaml @@ -542,17 +542,9 @@ paths: "endTime": "2010-03-04T12:12:12Z", "imageScale": 21.04, "layers": "[3,1,100]", - "eventsState": { - "tree_HEK": { - "labels_visible": true, - "layers": [ - { - "event_type": "flare", - "frms": ["frm10", "frm20"], - "event_instances": ["flare--frm1--event1", "flare--frm2--event2"] - } - ] - } + "event_selections": ["HEK>>Active Region", "HEK>>Flare>>SWPC"], + "event_visibility_selections": { + "HEK": { "marker_visibility": true, "label_visibility": true } }, "x1": -5000, "y1": -5000, @@ -829,17 +821,32 @@ paths: - $ref: '#/components/parameters/screenshotDate' - $ref: '#/components/parameters/screenshotImageScale' - $ref: '#/components/parameters/screenshotLayers' + - name: event_selections + in: query + required: false + description: > + Canonical event selections as a semicolon-separated list of + ``SOURCE>>Label[>>FRM]`` paths, e.g. + ``HEK>>Active Region;CCMC>>DONKI``. Takes precedence over the legacy + ``events`` bracket-string when both are supplied. URL-encode the + value (``;`` and ``>>`` in particular). + schema: + type: string - name: events in: query required: false description: > - Feature/event annotations (e.g. `[AR,HMI_HARP;SPoCA,1],[CH,all,1]`). + Legacy feature/event annotations as a bracket-string, e.g. + ``[AR,HMI_HARP;SPoCA,1],[CH,all,1]``. Superseded by + ``event_selections``; used only when ``event_selections`` is absent. schema: type: string - name: eventLabels in: query required: false - description: Annotate each event marker with a text label. + description: > + Annotate each event marker with a text label. Applies to whichever + of ``event_selections`` / ``events`` is used. schema: type: boolean - $ref: '#/components/parameters/screenshotScale' @@ -2160,6 +2167,42 @@ components: full: type: string + EventSelections: + type: array + maxItems: 200 + description: > + Canonical event selection paths, each a ``SOURCE>>Label[>>FRM[>>...]]`` + string (variable depth allowed). Upstream cap is 200 selections per + request. + items: + type: string + minLength: 1 + maxLength: 600 + example: ["HEK>>Active Region", "CCMC>>DONKI"] + + EventVisibilitySelections: + type: object + additionalProperties: false + description: > + Per-source marker/label visibility. Keys are canonical event source + names; both flags are optional per source and default to true + server-side when omitted. + properties: + HEK: { $ref: '#/components/schemas/EventSourceFlags' } + CCMC: { $ref: '#/components/schemas/EventSourceFlags' } + RHESSI: { $ref: '#/components/schemas/EventSourceFlags' } + WSA: { $ref: '#/components/schemas/EventSourceFlags' } + example: + HEK: { marker_visibility: true, label_visibility: false } + CCMC: { marker_visibility: true, label_visibility: true } + + EventSourceFlags: + type: object + additionalProperties: false + properties: + marker_visibility: { type: boolean } + label_visibility: { type: boolean } + PostMovieRequest: type: object required: [startTime, endTime, imageScale, layers] @@ -2175,11 +2218,10 @@ components: layers: type: string example: "[3,1,100]" - eventsState: - type: object - description: > - Optional feature/event annotation state. See the Helioviewer - events state documentation for structure details. + event_selections: + $ref: '#/components/schemas/EventSelections' + event_visibility_selections: + $ref: '#/components/schemas/EventVisibilitySelections' imageScale: type: number format: double @@ -2263,9 +2305,10 @@ components: layers: type: string example: "[3,1,100]" - eventsState: - type: object - description: Optional feature/event annotation state. + event_selections: + $ref: '#/components/schemas/EventSelections' + event_visibility_selections: + $ref: '#/components/schemas/EventVisibilitySelections' scale: type: boolean default: false From 539ba458945f442625c99b341c5ea9947b72e864 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 31 Jul 2026 20:02:31 +0000 Subject: [PATCH 21/31] use events.necdet.helioviewer.org url for testing the whole stack --- settings/Config.Example.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings/Config.Example.ini b/settings/Config.Example.ini index a1d0ca90..7fdc8529 100644 --- a/settings/Config.Example.ini +++ b/settings/Config.Example.ini @@ -86,7 +86,7 @@ db_events = true import_events_auth = "" ; This is the URL of the eventsapi, you can set to your development one if needed -events_api_url = "https://events.helioviewer.org" +events_api_url = "https://events.necdet.helioviewer.org" ; Timeout in seconds for Events API requests. Must comfortably exceed one ; chunk's time: at ~165ms/timestamp a 150-timestamp chunk is ~25s, and the From d06810e14c9a16eec4ba7d3aebbce6a87d04321e Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Wed, 5 Aug 2026 22:46:21 +0000 Subject: [PATCH 22/31] remove withDelta usage not necc anymore --- src/Event/Api/EventsApi.php | 5 +---- .../api/GetEventsForFramesWithSelectionsTest.php | 14 +++++++------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index 6ed5d98f..283bf3b0 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -217,10 +217,7 @@ public function getEventsForFramesWithSelections( $chunkSize = $maxChunk; } - // ?withDelta asks the upstream to include the per-observation rotation - // deltas (dx, dy) in the 'timestamps' block, so EventContext can shift - // each event's marker + footprint to the frame's timestamp. - $url = "/helioviewer/events/frames_with_selections?withDelta"; + $url = "/helioviewer/events/frames_with_selections"; $chunks = array_chunk($timestamps, $chunkSize); $fetchChunk = function (array $chunkTimestamps) use ($url, $selections) { diff --git a/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php b/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php index 5bd2c48a..40d06972 100644 --- a/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php +++ b/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php @@ -75,10 +75,10 @@ public function testItShouldFallBackToConfiguredChunkSizeWhenCallerPassesLessTha $this->mockClient->expects($this->exactly(2)) ->method('request') ->withConsecutive( - ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) use ($fallbackChunk) { + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) use ($fallbackChunk) { return count($options['json']['timestamps']) === $fallbackChunk; })], - ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) use ($tail) { + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) use ($tail) { return count($options['json']['timestamps']) === $tail; })] ) @@ -100,10 +100,10 @@ public function testItShouldCapChunkSizeAtUpstreamLimit(): void $this->mockClient->expects($this->exactly(2)) ->method('request') ->withConsecutive( - ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) { + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { return count($options['json']['timestamps']) === 150; })], - ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) { + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { return count($options['json']['timestamps']) === 50; })] ) @@ -126,15 +126,15 @@ public function testItShouldPaginateTimestampsAtTheGivenChunkSize(): void $this->mockClient->expects($this->exactly(3)) ->method('request') ->withConsecutive( - ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) { + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { return count($options['json']['timestamps']) === 3 && $options['json']['selections'] === ['HEK>>Flare', 'CCMC>>DONKI>>CME']; })], - ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) { + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { return count($options['json']['timestamps']) === 3 && $options['json']['selections'] === ['HEK>>Flare', 'CCMC>>DONKI>>CME']; })], - ['POST', '/helioviewer/events/frames_with_selections?withDelta', $this->callback(function ($options) { + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { return count($options['json']['timestamps']) === 1 && $options['json']['selections'] === ['HEK>>Flare', 'CCMC>>DONKI>>CME']; })] From 1ae04c471c4462e1427310af0ce4022bb6a9c5e9 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 28 Sep 2026 16:55:49 +0000 Subject: [PATCH 23/31] apply per frame going behind the sun for event pins --- src/Event/EventContext.php | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/Event/EventContext.php b/src/Event/EventContext.php index 63a3fefc..da22c896 100644 --- a/src/Event/EventContext.php +++ b/src/Event/EventContext.php @@ -27,9 +27,14 @@ * 'label' => 'AR 13700', // empty string '' when labels are hidden for this source * 'type' => 'AR', * 'pin' => 'AR', + * 'visible' => true, // false = event is behind the sun this frame (dim the pin) * 'hv_hpc_x' => -119.0, // rotated for this frame * 'hv_hpc_y' => 570.2, - * 'footprint' => [[{x,y}, ...], ...], // list of rings; every point already shifted by (dx,dy) + * 'footprint' => [[{x,y,visible?}, ...], ...], // list of rings; every point already shifted by + * // (dx,dy). A point carries visible=false only when + * // it is behind the sun; the key is absent otherwise, + * // mirroring the wire shape so the renderer's far-side + * // test stays a strict === false. * ] * * NOTE: there is no separate label_visibility flag. The renderer treats an @@ -210,7 +215,18 @@ public function getEventsForDate(string $date): array if (!empty($rawFootprint)) { $footprint = array_map( fn(array $ring) => array_map( - fn($p) => ['x' => $p['x'] + $dx, 'y' => $p['y'] + $dy], + function ($p) use ($dx, $dy) { + $shifted = ['x' => $p['x'] + $dx, 'y' => $p['y'] + $dy]; + // Preserve the far-side flag. Upstream sends visible=false + // for points behind the sun and omits the key on near-side + // points, so we keep exactly that: copy the key only when it + // is === false. The renderer's contour classification tests + // strictly against false, matching the web client. + if (($p['visible'] ?? null) === false) { + $shifted['visible'] = false; + } + return $shifted; + }, $ring ), $rawFootprint @@ -223,6 +239,10 @@ public function getEventsForDate(string $date): array 'label' => $labelVisible ? $event->get('label', '') : '', 'type' => $type, 'pin' => $event->get('pin', $type), + // Per-frame event-level visibility from the observations block: + // false = the event is behind the sun this frame (dim the pin). + // Absent -> assume near side (true). + 'visible' => ($coords['visible'] ?? true) !== false, 'hv_hpc_x' => $event->get('hv_hpc_x', 0.0) + $dx, 'hv_hpc_y' => $event->get('hv_hpc_y', 0.0) + $dy, 'footprint' => $footprint, From 6a72ef1fb78c2a31a4a9e7d2c130bd89235ac01e Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 28 Sep 2026 16:58:39 +0000 Subject: [PATCH 24/31] tests for event_context change for going behind the sun --- tests/unit_tests/events/EventContextTest.php | 45 ++++ .../events/FootprintContourTest.php | 210 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 tests/unit_tests/events/FootprintContourTest.php diff --git a/tests/unit_tests/events/EventContextTest.php b/tests/unit_tests/events/EventContextTest.php index 25dfd5b0..f8cb7c7d 100644 --- a/tests/unit_tests/events/EventContextTest.php +++ b/tests/unit_tests/events/EventContextTest.php @@ -105,6 +105,51 @@ public function testHappyPathShiftsFootprintByRotationDelta(): void $this->assertSame(120.0, $events[0]['hv_hpc_x']); $this->assertSame(230.0, $events[0]['hv_hpc_y']); $this->assertTrue($context->hasEvents()); + // Observation omits 'visible' -> event assumed near side (true). + $this->assertTrue($events[0]['visible']); + } + + public function testFootprintPreservesFarSideFlagAndDropsItNearSide(): void + { + $eventId = 'event-uuid'; + $ts = '2024-01-01T00:00:00.000Z'; + $this->mockApi->method('getEventsForFramesWithSelections')->willReturn([ + 'events' => [ + $eventId => [ + 'label' => 'CH', + 'type' => 'CH', + 'pin' => 'CH', + 'path' => 'WSA>>Coronal Hole>>SO', + 'hv_hpc_x' => 0.0, + 'hv_hpc_y' => 0.0, + 'footprint' => [[ + ['x' => 10.0, 'y' => 20.0], // near side: key absent + ['x' => 30.0, 'y' => 40.0, 'visible' => false], // far side + ]], + ], + ], + 'timestamps' => [ + // event is behind the sun this frame -> dim the pin + $ts => [$eventId => ['dx' => 1.0, 'dy' => 2.0, 'visible' => false]], + ], + ]); + + $context = EventContext::build([$ts], ['WSA>>Coronal Hole>>SO'], [], $this->mockApi); + $events = $context->getEventsForDate($ts); + $ring = $events[0]['footprint'][0]; + + // Near-side point: shifted by (dx,dy), NO 'visible' key (mirrors the wire shape). + $this->assertSame(11.0, $ring[0]['x']); + $this->assertSame(22.0, $ring[0]['y']); + $this->assertArrayNotHasKey('visible', $ring[0]); + + // Far-side point: shifted, visible=false preserved. + $this->assertSame(31.0, $ring[1]['x']); + $this->assertSame(42.0, $ring[1]['y']); + $this->assertFalse($ring[1]['visible']); + + // Event-level visibility flows through from the observations block. + $this->assertFalse($events[0]['visible']); } public function testHiddenLabelIsEncodedAsEmptyString(): void diff --git a/tests/unit_tests/events/FootprintContourTest.php b/tests/unit_tests/events/FootprintContourTest.php new file mode 100644 index 00000000..022ebf9b --- /dev/null +++ b/tests/unit_tests/events/FootprintContourTest.php @@ -0,0 +1,210 @@ + + */ + +use PHPUnit\Framework\TestCase; +use Helioviewer\Api\Event\FootprintContour; + +final class FootprintContourTest extends TestCase +{ + /** A single HPC point; pass $behind=true to flag it far-side (visible:false). */ + private static function pt(float $x, float $y, bool $behind = false): array + { + $p = ['x' => $x, 'y' => $y]; + if ($behind) { + $p['visible'] = false; + } + return $p; + } + + /** + * n points evenly spaced on a circle of radius $r (CCW, angle increasing). + * $behindIdx flags the given indices as far-side. + * + * @param int[] $behindIdx + */ + private static function circle(int $n, float $r, array $behindIdx = []): array + { + $behind = array_fill_keys($behindIdx, true); + $points = []; + for ($i = 0; $i < $n; $i++) { + $th = $i * (2 * M_PI / $n); + $points[] = self::pt($r * cos($th), $r * sin($th), isset($behind[$i])); + } + return $points; + } + + // --- classification ----------------------------------------------------- + + public function testFrontContourYieldsOneFillAndNothingElse(): void + { + $c = new FootprintContour(self::circle(8, 300.0)); + $this->assertTrue($c->isFront()); + $this->assertFalse($c->isPartial()); + + $shapes = $c->shapes(); + $this->assertCount(1, $shapes['fills']); + $this->assertSame([], $shapes['ghosts']); + $this->assertSame([], $shapes['tints']); + $this->assertTrue($shapes['fills'][0]['closed']); + $this->assertSame(8, count($shapes['fills'][0]['points'])); + } + + public function testBehindContourYieldsOneGhostAndOneTintNoFill(): void + { + $c = new FootprintContour(self::circle(8, 300.0, [0, 1, 2, 3, 4, 5, 6, 7])); + $this->assertTrue($c->isBehind()); + $this->assertFalse($c->isPartial()); + + $shapes = $c->shapes(); + $this->assertSame([], $shapes['fills']); + $this->assertCount(1, $shapes['ghosts']); + $this->assertCount(1, $shapes['tints']); + $this->assertTrue($shapes['ghosts'][0]['closed']); + $this->assertTrue($shapes['tints'][0]['closed']); + } + + // --- runs / wrap-merge -------------------------------------------------- + + /** + * The documented example: F F B B B B F F F F. The near-side arc listed as + * (6..9) + (0,1) must merge, tail before head, into ONE run in walking order + * (6,7,8,9,0,1); the behind arc (2,3,4,5) is the other run. + */ + public function testRunsWrapMergeKeepsNearSideArcWhole(): void + { + $points = self::circle(10, 900.0, [2, 3, 4, 5]); + $runs = (new FootprintContour($points))->runs(); + + $this->assertCount(2, $runs); + + // run 0: the merged near-side arc + $this->assertFalse($runs[0]['behindSun']); + $this->assertSame(6, $runs[0]['first']); + $this->assertSame(1, $runs[0]['last']); + $this->assertSame(6, count($runs[0]['points'])); + + // run 1: the behind arc + $this->assertTrue($runs[1]['behindSun']); + $this->assertSame(2, $runs[1]['first']); + $this->assertSame(5, $runs[1]['last']); + $this->assertSame(4, count($runs[1]['points'])); + } + + public function testRunsNoMergeWhenEndsDiffer(): void + { + // F B B ... B F ... : first run front, last run behind -> no merge. + $points = self::circle(6, 900.0, [1, 2, 3]); + $runs = (new FootprintContour($points))->runs(); + // runs: [F(0)], [B(1,2,3)], [F(4,5)] -> ends differ (F vs F)? idx0 F, idx5 F + // Actually 0 F, 1-3 B, 4-5 F -> first F, last F -> merges into 2. + $this->assertCount(2, $runs); + } + + // --- straddler shapes --------------------------------------------------- + + public function testStraddlerYieldsOneFillOneGhostOneTint(): void + { + // One behind run (single limb crossing) -> exactly one of each kind. + $c = new FootprintContour(self::circle(10, 900.0, [2, 3, 4, 5])); + $this->assertTrue($c->isPartial()); + + $shapes = $c->shapes(); + $this->assertCount(1, $shapes['ghosts'], 'one behind run -> one ghost'); + $this->assertCount(1, $shapes['fills'], 'in-front runs chain into one fill'); + $this->assertCount(1, $shapes['tints'], 'behind runs chain into one tint'); + + // The ghost is an OPEN polyline extended by one near-side neighbour at + // each end (4 behind points + 2 neighbours = 6). + $this->assertFalse($shapes['ghosts'][0]['closed']); + $this->assertSame(6, count($shapes['ghosts'][0]['points'])); + + // Fills/tints are closed and non-trivial. + $this->assertTrue($shapes['fills'][0]['closed']); + $this->assertGreaterThanOrEqual(3, count($shapes['fills'][0]['points'])); + $this->assertTrue($shapes['tints'][0]['closed']); + $this->assertGreaterThanOrEqual(3, count($shapes['tints'][0]['points'])); + } + + public function testStraddlerWithTwoBehindRunsYieldsTwoGhosts(): void + { + // F B B F F F B B F F : crosses the limb twice -> two behind runs, + // hence two ghosts; the two in-front runs chain (via limb arcs) into a + // single fill polygon with a notch. + $c = new FootprintContour(self::circle(10, 900.0, [1, 2, 6, 7])); + $this->assertTrue($c->isPartial()); + + $shapes = $c->shapes(); + $this->assertCount(2, $shapes['ghosts']); + $this->assertNotEmpty($shapes['fills']); + $this->assertNotEmpty($shapes['tints']); + } + + // --- regionSide --------------------------------------------------------- + + public function testRegionSideFlipsWhenWalkReverses(): void + { + $ccw = self::circle(12, 300.0); // angle increasing + $cw = array_reverse($ccw); // same loop, opposite walk + + $sideCcw = (new FootprintContour($ccw))->regionSide(); + $sideCw = (new FootprintContour($cw))->regionSide(); + + $this->assertContains($sideCcw, [-1, 1]); + $this->assertSame(-$sideCcw, $sideCw, 'reversing the walk flips the region side'); + } + + public function testRegionSideIsLeftForNearSideCcwLoop(): void + { + // A small near-side loop walked counter-clockwise encloses its area on + // the LEFT of the walk -> +1. + $this->assertSame(1, (new FootprintContour(self::circle(12, 300.0)))->regionSide()); + } + + /** + * Regression: a degenerate 2-point all-behind contour, seen in real WSA CH + * data (e.g. Coronal Hole >> AGONG >> R11). It classifies as isBehind, so + * shapes() yields one closed ghost + one closed tint, each with 2 points. + * The renderer must NOT hand a 2-point *closed* shape to ImagickDraw::polygon + * (which needs >=3 points, else "non-conforming primitive") — it downgrades + * such a ghost to a polyline and skips the 2-point tint. + */ + public function testTwoPointBehindContourClassifiesAsBehind(): void + { + $c = new FootprintContour([ + self::pt(900.0, 100.0, true), + self::pt(880.0, 140.0, true), + ]); + $this->assertTrue($c->isBehind()); + + $s = $c->shapes(); + $this->assertCount(1, $s['ghosts']); + $this->assertCount(1, $s['tints']); + $this->assertSame(2, count($s['ghosts'][0]['points'])); + $this->assertTrue($s['ghosts'][0]['closed']); + $this->assertSame([], $s['fills']); + } + + // --- misc --------------------------------------------------------------- + + public function testEveryShapeHasKindClosedAndPoints(): void + { + $shapes = (new FootprintContour(self::circle(10, 900.0, [2, 3, 4, 5])))->shapes(); + foreach (['tints', 'ghosts', 'fills'] as $group) { + foreach ($shapes[$group] as $shape) { + $this->assertArrayHasKey('kind', $shape); + $this->assertArrayHasKey('closed', $shape); + $this->assertArrayHasKey('points', $shape); + $this->assertNotEmpty($shape['points']); + foreach ($shape['points'] as $p) { + $this->assertArrayHasKey('x', $p); + $this->assertArrayHasKey('y', $p); + $this->assertIsFloat($p['x']); + $this->assertIsFloat($p['y']); + } + } + } + } +} From c517b24e121f00ebd688ccefa329ddfd4fe7d93b Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 28 Sep 2026 19:34:00 +0000 Subject: [PATCH 25/31] Add contour geometry and shape object which will be the individual regions to be drawed. this commit adds the mathematics to split a region into the front and far side of the side --- src/Event/FootprintContour.php | 456 ++++++++++++++++++ src/Event/Shape.php | 52 ++ .../events/FootprintContourTest.php | 47 +- tests/unit_tests/events/ShapeTest.php | 37 ++ 4 files changed, 573 insertions(+), 19 deletions(-) create mode 100644 src/Event/FootprintContour.php create mode 100644 src/Event/Shape.php create mode 100644 tests/unit_tests/events/ShapeTest.php diff --git a/src/Event/FootprintContour.php b/src/Event/FootprintContour.php new file mode 100644 index 00000000..fc168f9d --- /dev/null +++ b/src/Event/FootprintContour.php @@ -0,0 +1,456 @@ + ghosts -> fills. + * + * Everything a contour can say about itself is computed once and memoized: + * which side of the sun it is on (isFront / isBehind / isPartial), its runs, + * which side of the walk the region lies on (regionSide), and its shapes. + * + * @author Kasim Necdet Percinel + */ +class FootprintContour +{ + private const TWO_PI = 2 * M_PI; + private const FOUR_PI = 4 * M_PI; + + /** @var array */ + private array $points; + private int $behindCount; + + /** @var array|null memoized runs() */ + private ?array $runs = null; + /** @var int|null memoized regionSide() */ + private ?int $side = null; + /** @var array|null memoized shapes() */ + private ?array $shapes = null; + + /** + * @param array $points + * Closed polygon of HPC-arcsec points. A point is behind the sun when + * its 'visible' key is === false; the key is absent for near-side points. + */ + public function __construct(array $points) + { + $this->points = array_values($points); + $this->behindCount = 0; + foreach ($this->points as $p) { + if (self::isPointBehindSun($p)) { + $this->behindCount++; + } + } + } + + /** + * True when a footprint point is behind the sun. The API sends visible:false + * for far-side points and omits the key for near-side ones, so the test is + * strictly "=== false". (Unrelated to the marker/label visibility toggles.) + */ + public static function isPointBehindSun(array $point): bool + { + return ($point['visible'] ?? null) === false; + } + + public function length(): int + { + return count($this->points); + } + + /** Every point on the near side: drawn as one plain fill. */ + public function isFront(): bool + { + return $this->behindCount === 0; + } + + /** Every point behind the sun: drawn as a tint under a closed dashed outline. */ + public function isBehind(): bool + { + return $this->behindCount === count($this->points); + } + + /** Straddles the limb: cut into runs; fills closed along the limb, far side ghosted and tinted. */ + public function isPartial(): bool + { + return !$this->isFront() && !$this->isBehind(); + } + + /** + * The contour's RUNS, computed once. A run is a stretch of consecutive + * points all on the same side of the sun. Walking the contour, a new run + * starts every time the behind-sun flag flips. Because the contour is + * closed, a near-side arc that the API happened to list starting in its + * middle shows up as a first and a last run of the same side; the wrap step + * merges them (tail before head) so the arc stays one run in walking order. + * + * @return array + */ + public function runs(): array + { + if ($this->runs === null) { + $runs = []; + foreach ($this->points as $index => $point) { + $behind = self::isPointBehindSun($point); + $lastIdx = count($runs) - 1; + if ($lastIdx >= 0 && $runs[$lastIdx]['behindSun'] === $behind) { + // same side as the previous point: extend the current run + $runs[$lastIdx]['points'][] = $point; + $runs[$lastIdx]['last'] = $index; + } else { + // the flag flipped (or this is the first point): start a new run + $runs[] = ['behindSun' => $behind, 'points' => [$point], 'first' => $index, 'last' => $index]; + } + } + // Wrap-around: if the list started in the middle of an arc, the first + // and last runs are the same arc. Merge them, tail before head. + $count = count($runs); + if ($count > 1 && $runs[0]['behindSun'] === $runs[$count - 1]['behindSun']) { + $tail = array_pop($runs); + $runs[0]['points'] = array_merge($tail['points'], $runs[0]['points']); + $runs[0]['first'] = $tail['first']; + } + $this->runs = $runs; + } + return $this->runs; + } + + /** + * Which side of the walk the region is on, computed once. A closed curve on + * a sphere bounds two complementary areas; the region is the SMALLER one. + * The contour is rebuilt in 3D (z = +sqrt(R^2-r^2) in front, - behind; + * R = the contour's largest radius, i.e. the limb) and the signed solid + * angle of the fan of spherical triangles from a reference direction is + * summed (Van Oosterom & Strackee). Reduced to [0, 4*pi) that is the area on + * the LEFT of the walk; if it is the smaller half the region is on the left. + * + * @return int +1 region on the left of the walking direction, -1 on the right + */ + public function regionSide(): int + { + if ($this->side === null) { + $R = 0.0; + foreach ($this->points as $p) { + $r = hypot($p['x'], $p['y']); + if ($r > $R) { + $R = $r; + } + } + if ($R <= 0.0) { + $R = 1.0; + } + + $u = []; + foreach ($this->points as $p) { + $x = $p['x'] / $R; + $y = $p['y'] / $R; + $z = sqrt(max(0.0, 1 - $x * $x - $y * $y)); + $u[] = [$x, $y, self::isPointBehindSun($p) ? -$z : $z]; + } + + // reference direction: the normalized mean of the points, else Earth + $ref = [0.0, 0.0, 0.0]; + foreach ($u as $v) { + $ref[0] += $v[0]; + $ref[1] += $v[1]; + $ref[2] += $v[2]; + } + $norm = sqrt($ref[0] * $ref[0] + $ref[1] * $ref[1] + $ref[2] * $ref[2]); + $ref = $norm > 1e-6 + ? [$ref[0] / $norm, $ref[1] / $norm, $ref[2] / $norm] + : [0.0, 0.0, 1.0]; + + $dot = fn(array $a, array $b): float => $a[0] * $b[0] + $a[1] * $b[1] + $a[2] * $b[2]; + $triple = fn(array $a, array $b, array $c): float => + $a[0] * ($b[1] * $c[2] - $b[2] * $c[1]) + - $a[1] * ($b[0] * $c[2] - $b[2] * $c[0]) + + $a[2] * ($b[0] * $c[1] - $b[1] * $c[0]); + + $omega = 0.0; + $count = count($u); + for ($i = 0; $i < $count; $i++) { + $b = $u[$i]; + $c = $u[($i + 1) % $count]; + $omega += 2 * atan2($triple($ref, $b, $c), 1 + $dot($ref, $b) + $dot($b, $c) + $dot($c, $ref)); + } + $leftArea = self::posmod($omega, self::FOUR_PI); + $this->side = $leftArea <= self::TWO_PI ? 1 : -1; + } + return $this->side; + } + + /** + * The SHAPES to draw for this contour, computed once. + * + * @return array{tints: Shape[], ghosts: Shape[], fills: Shape[]} the far-side + * tints, the far-side ghosts and the near-side fills, each a list of Shape. + */ + public function shapes(): array + { + if ($this->shapes === null) { + $this->shapes = $this->computeShapes(); + } + return $this->shapes; + } + + private function computeShapes(): array + { + $fills = []; + $ghosts = []; + $tints = []; + + if ($this->isFront()) { + // whole contour on the near side: one filled polygon + $fills[] = new Shape(Shape::FILL, true, $this->points); + return ['tints' => $tints, 'ghosts' => $ghosts, 'fills' => $fills]; + } + if ($this->isBehind()) { + // whole contour behind the sun: one dashed closed outline, tinted inside + $ghosts[] = new Shape(Shape::GHOST, true, $this->points); + $tints[] = new Shape(Shape::TINT, true, $this->points); + return ['tints' => $tints, 'ghosts' => $ghosts, 'fills' => $fills]; + } + + // Straddler: split into runs. Each behind run becomes its own ghost; the + // in-front runs become fills closed along the limb. + $n = count($this->points); + $runs = $this->runs(); + $runCount = count($runs); + $side = $this->regionSide(); + + $ccw = fn(float $from, float $to): float => self::posmod($to - $from, self::TWO_PI); + // signed sweep from angle $from to $to going the region's way round the limb + $sweepTo = fn(float $from, float $to): float => + $side > 0 ? $ccw($from, $to) : -(self::TWO_PI - $ccw($from, $to)); + + // every place an in-front run enters or leaves the disk, with its limb angle + $crossings = []; + foreach ($runs as $idx => $run) { + if (!$run['behindSun']) { + $rp = $run['points']; + $crossings[] = ['th' => self::angle($rp[0]), 'entry' => true, 'run' => $idx]; + $crossings[] = ['th' => self::angle($rp[count($rp) - 1]), 'entry' => false, 'run' => $idx]; + } + } + + $nextRun = []; // in-front run index -> the run its limb arc leads to + $arcAfter = []; // in-front run index -> limb arc points after its last point + $extended = []; // behind run index -> { before, after, points } + + foreach ($runs as $idx => $run) { + if ($run['behindSun']) { + // Behind arc: dashed open line, EXTENDED by the in-front neighbour + // at each end so it starts/ends exactly on the fill's edge. + $before = $this->points[($run['first'] - 1 + $n) % $n]; + $after = $this->points[($run['last'] + 1) % $n]; + $extended[$idx] = [ + 'before' => $before, + 'after' => $after, + 'points' => array_merge([$before], $run['points'], [$after]), + ]; + $ghosts[] = new Shape(Shape::GHOST, false, $extended[$idx]['points']); + continue; + } + // In-front run: from its last point, walk the limb the region's way + // round to the nearest crossing; that is where the visible boundary + // continues. + $rp = $run['points']; + $last = $rp[count($rp) - 1]; + $thLast = self::angle($last); + // skip the crossing we are leaving from + $best = self::nearestCrossing( + $thLast, $crossings, $sweepTo, + fn(array $c): bool => $c['run'] === $idx && !$c['entry'] + ); + $target = $idx; + if ($best !== null && $best['crossing']['entry']) { + $target = $best['crossing']['run']; + $sweep = $best['sweep']; + } else { + // not a re-entry (open, seam-cut contour): close this run on itself the short way + $s = $ccw($thLast, self::angle($rp[0])); + $sweep = $s <= M_PI ? $s : -(self::TWO_PI - $s); + } + $nextRun[$idx] = $target; + $arcAfter[$idx] = self::limbArc($last, $runs[$target]['points'][0], $sweep); + } + + // Chain run -> arc -> next run -> ... into closed polygons. + $done = []; + foreach ($runs as $idx => $run) { + if ($run['behindSun'] || !empty($done[$idx])) { + continue; + } + $points = []; + $i = $idx; + $guard = 0; + do { + $done[$i] = true; + $points = array_merge($points, $runs[$i]['points'], $arcAfter[$i]); + $i = $nextRun[$i]; + $guard++; + } while ($i !== $idx && empty($done[$i]) && $guard < $runCount); + if (count($points) >= 3) { + $fills[] = new Shape(Shape::FILL, true, $points); + } + } + + // Far-side tint: the region clipped to the FAR hemisphere, built like the + // fills but mirrored. From where a behind run comes back in front (its + // `after`) the boundary follows the limb the OTHER way round to the + // nearest crossing (some behind run's `before`). Chain into polygons. + $sweepBack = fn(float $from, float $to): float => + $side > 0 ? -(self::TWO_PI - $ccw($from, $to)) : $ccw($from, $to); + + $farCrossings = []; + foreach ($extended as $idx => $ex) { + $farCrossings[] = ['th' => self::angle($ex['before']), 'exit' => true, 'run' => $idx]; + $farCrossings[] = ['th' => self::angle($ex['after']), 'exit' => false, 'run' => $idx]; + } + $nextBehind = []; + $arcBehind = []; + foreach ($extended as $idx => $ex) { + $after = $ex['after']; + $thAfter = self::angle($after); + // skip the crossing we are leaving from + $best = self::nearestCrossing( + $thAfter, $farCrossings, $sweepBack, + fn(array $c): bool => $c['run'] === $idx && !$c['exit'] + ); + $target = $idx; + if ($best !== null && $best['crossing']['exit']) { + $target = $best['crossing']['run']; + $sweep = $best['sweep']; + } else { + // not an exit (open, seam-cut contour): close this run's tint on itself the short way + $s = $ccw($thAfter, self::angle($ex['before'])); + $sweep = $s <= M_PI ? $s : -(self::TWO_PI - $s); + } + $nextBehind[$idx] = $target; + $arcBehind[$idx] = self::limbArc($after, $extended[$target]['before'], $sweep); + } + $doneBehind = []; + foreach ($extended as $idx => $ex) { + if (!empty($doneBehind[$idx])) { + continue; + } + $points = []; + $i = $idx; + $guard = 0; + do { + $doneBehind[$i] = true; + $points = array_merge($points, $extended[$i]['points'], $arcBehind[$i]); + $i = $nextBehind[$i]; + $guard++; + } while ($i !== $idx && empty($doneBehind[$i]) && $guard < $runCount); + if (count($points) >= 3) { + $tints[] = new Shape(Shape::TINT, true, $points); + } + } + + return ['tints' => $tints, 'ghosts' => $ghosts, 'fills' => $fills]; + } + + /** + * Intermediate points along the solar limb between crossing points $a and + * $b, so a fill is closed along the limb instead of with a straight chord. + * The radius is interpolated from |a| to |b|, so no solar-radius constant is + * needed. About one point every 2 degrees. + * + * @return array intermediate points only + * (a and b themselves are not repeated) + */ + private static function limbArc(array $a, array $b, float $sweep): array + { + if (abs($sweep) < 1e-6) { + return []; + } + $thA = atan2($a['y'], $a['x']); + $rA = hypot($a['x'], $a['y']); + $rB = hypot($b['x'], $b['y']); + $steps = max(1, (int) round(abs($sweep) / (M_PI / 90))); + $points = []; + for ($i = 1; $i < $steps; $i++) { + $t = $i / $steps; + $th = $thA + $sweep * $t; + $r = $rA + ($rB - $rA) * $t; + $points[] = ['x' => $r * cos($th), 'y' => $r * sin($th)]; + } + return $points; + } + + /** + * Nearest crossing from angle $from, walking the limb in the direction given + * by $sweepFn (a signed sweep function). + * + * Half-turn rule (spec section 8e): search the region's way round for the + * smallest |sweep|; if that nearest crossing is more than half a turn away + * (|sweep| > pi), it is wrong -- no visible region straddles more than half + * the limb -- so re-run the same search the OTHER way round and take that. + * This covers a seam-cut contour whose fake closing edge flips the region + * side (its near-side sliver would otherwise close the long way and fill the + * disk), and two crossings a hair apart landing in the wrong angular order + * through coordinate noise. It subsumes the earlier 2-degree guard. + * + * @param callable $sweepFn fn(float $from, float $to): float signed sweep, region's way + * @param callable $skip fn(array $crossing): bool crossing to ignore + * @return array{crossing: array, sweep: float}|null null when there is no other crossing + */ + private static function nearestCrossing(float $from, array $crossings, callable $sweepFn, callable $skip): ?array + { + $best = self::searchCrossings($from, $crossings, $sweepFn, $skip); + if ($best !== null && abs($best['sweep']) > M_PI) { + // The nearest hit the region's way is beyond a half turn -> wrong. + // Walk the other way: the complementary sweep in the opposite direction. + $otherWay = function (float $a, float $b) use ($sweepFn): float { + $s = $sweepFn($a, $b); + return -($s <=> 0) * (self::TWO_PI - abs($s)); + }; + $best = self::searchCrossings($from, $crossings, $otherWay, $skip); + } + return $best; + } + + /** + * Smallest-|sweep| crossing under a given signed-sweep function. + * + * @return array{crossing: array, sweep: float}|null + */ + private static function searchCrossings(float $from, array $crossings, callable $sweepFn, callable $skip): ?array + { + $best = null; + foreach ($crossings as $c) { + if ($skip($c)) { + continue; + } + $sweep = $sweepFn($from, $c['th']); + if ($best === null || abs($sweep) < abs($best['sweep'])) { + $best = ['crossing' => $c, 'sweep' => $sweep]; + } + } + return $best; + } + + private static function angle(array $p): float + { + return atan2($p['y'], $p['x']); + } + + /** Positive modulo, matching JS ((x % m) + m) % m. */ + private static function posmod(float $x, float $m): float + { + return fmod(fmod($x, $m) + $m, $m); + } +} diff --git a/src/Event/Shape.php b/src/Event/Shape.php new file mode 100644 index 00000000..1b1fcf80 --- /dev/null +++ b/src/Event/Shape.php @@ -0,0 +1,52 @@ + + */ +final class Shape +{ + public const TINT = 'tint'; + public const GHOST = 'ghost'; + public const FILL = 'fill'; + + /** + * @param self::TINT|self::GHOST|self::FILL $kind + * @param bool $closed closed polygon vs open polyline + * @param array $points HPC arcsec + */ + public function __construct( + public readonly string $kind, + public readonly bool $closed, + public readonly array $points + ) { + } + + public function isTint(): bool + { + return $this->kind === self::TINT; + } + + public function isGhost(): bool + { + return $this->kind === self::GHOST; + } + + public function isFill(): bool + { + return $this->kind === self::FILL; + } + + /** Number of points in the shape. */ + public function count(): int + { + return count($this->points); + } +} diff --git a/tests/unit_tests/events/FootprintContourTest.php b/tests/unit_tests/events/FootprintContourTest.php index 022ebf9b..1c978c5f 100644 --- a/tests/unit_tests/events/FootprintContourTest.php +++ b/tests/unit_tests/events/FootprintContourTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Helioviewer\Api\Event\FootprintContour; +use Helioviewer\Api\Event\Shape; final class FootprintContourTest extends TestCase { @@ -48,8 +49,10 @@ public function testFrontContourYieldsOneFillAndNothingElse(): void $this->assertCount(1, $shapes['fills']); $this->assertSame([], $shapes['ghosts']); $this->assertSame([], $shapes['tints']); - $this->assertTrue($shapes['fills'][0]['closed']); - $this->assertSame(8, count($shapes['fills'][0]['points'])); + $this->assertInstanceOf(Shape::class, $shapes['fills'][0]); + $this->assertTrue($shapes['fills'][0]->isFill()); + $this->assertTrue($shapes['fills'][0]->closed); + $this->assertSame(8, $shapes['fills'][0]->count()); } public function testBehindContourYieldsOneGhostAndOneTintNoFill(): void @@ -62,8 +65,8 @@ public function testBehindContourYieldsOneGhostAndOneTintNoFill(): void $this->assertSame([], $shapes['fills']); $this->assertCount(1, $shapes['ghosts']); $this->assertCount(1, $shapes['tints']); - $this->assertTrue($shapes['ghosts'][0]['closed']); - $this->assertTrue($shapes['tints'][0]['closed']); + $this->assertTrue($shapes['ghosts'][0]->closed); + $this->assertTrue($shapes['tints'][0]->closed); } // --- runs / wrap-merge -------------------------------------------------- @@ -118,14 +121,14 @@ public function testStraddlerYieldsOneFillOneGhostOneTint(): void // The ghost is an OPEN polyline extended by one near-side neighbour at // each end (4 behind points + 2 neighbours = 6). - $this->assertFalse($shapes['ghosts'][0]['closed']); - $this->assertSame(6, count($shapes['ghosts'][0]['points'])); + $this->assertFalse($shapes['ghosts'][0]->closed); + $this->assertSame(6, $shapes['ghosts'][0]->count()); // Fills/tints are closed and non-trivial. - $this->assertTrue($shapes['fills'][0]['closed']); - $this->assertGreaterThanOrEqual(3, count($shapes['fills'][0]['points'])); - $this->assertTrue($shapes['tints'][0]['closed']); - $this->assertGreaterThanOrEqual(3, count($shapes['tints'][0]['points'])); + $this->assertTrue($shapes['fills'][0]->closed); + $this->assertGreaterThanOrEqual(3, $shapes['fills'][0]->count()); + $this->assertTrue($shapes['tints'][0]->closed); + $this->assertGreaterThanOrEqual(3, $shapes['tints'][0]->count()); } public function testStraddlerWithTwoBehindRunsYieldsTwoGhosts(): void @@ -182,23 +185,29 @@ public function testTwoPointBehindContourClassifiesAsBehind(): void $s = $c->shapes(); $this->assertCount(1, $s['ghosts']); $this->assertCount(1, $s['tints']); - $this->assertSame(2, count($s['ghosts'][0]['points'])); - $this->assertTrue($s['ghosts'][0]['closed']); + $this->assertSame(2, $s['ghosts'][0]->count()); + $this->assertTrue($s['ghosts'][0]->closed); $this->assertSame([], $s['fills']); } // --- misc --------------------------------------------------------------- - public function testEveryShapeHasKindClosedAndPoints(): void + public function testEveryShapeIsAShapeObjectAndGroupMatchesKind(): void { + $expectedKind = [ + 'tints' => Shape::TINT, + 'ghosts' => Shape::GHOST, + 'fills' => Shape::FILL, + ]; $shapes = (new FootprintContour(self::circle(10, 900.0, [2, 3, 4, 5])))->shapes(); - foreach (['tints', 'ghosts', 'fills'] as $group) { + foreach ($expectedKind as $group => $kind) { foreach ($shapes[$group] as $shape) { - $this->assertArrayHasKey('kind', $shape); - $this->assertArrayHasKey('closed', $shape); - $this->assertArrayHasKey('points', $shape); - $this->assertNotEmpty($shape['points']); - foreach ($shape['points'] as $p) { + $this->assertInstanceOf(Shape::class, $shape); + $this->assertSame($kind, $shape->kind); // the group matches the Shape's kind + $this->assertIsBool($shape->closed); + $this->assertNotEmpty($shape->points); + $this->assertSame(count($shape->points), $shape->count()); + foreach ($shape->points as $p) { $this->assertArrayHasKey('x', $p); $this->assertArrayHasKey('y', $p); $this->assertIsFloat($p['x']); diff --git a/tests/unit_tests/events/ShapeTest.php b/tests/unit_tests/events/ShapeTest.php new file mode 100644 index 00000000..5380c123 --- /dev/null +++ b/tests/unit_tests/events/ShapeTest.php @@ -0,0 +1,37 @@ + + */ + +use PHPUnit\Framework\TestCase; +use Helioviewer\Api\Event\Shape; + +final class ShapeTest extends TestCase +{ + public function testHoldsKindClosedAndPoints(): void + { + $pts = [['x' => 1.0, 'y' => 2.0], ['x' => 3.0, 'y' => 4.0], ['x' => 5.0, 'y' => 6.0]]; + $s = new Shape(Shape::FILL, true, $pts); + + $this->assertSame(Shape::FILL, $s->kind); + $this->assertTrue($s->closed); + $this->assertSame($pts, $s->points); + $this->assertSame(3, $s->count()); + } + + public function testKindPredicates(): void + { + $this->assertTrue((new Shape(Shape::TINT, true, []))->isTint()); + $this->assertTrue((new Shape(Shape::GHOST, false, []))->isGhost()); + $this->assertTrue((new Shape(Shape::FILL, true, []))->isFill()); + $this->assertFalse((new Shape(Shape::TINT, true, []))->isFill()); + } + + public function testPropertiesAreReadonly(): void + { + $s = new Shape(Shape::FILL, true, []); + $this->expectException(\Error::class); // "Cannot modify readonly property" + $s->kind = Shape::TINT; + } +} From a1d3837672870f18eee9d67073d7082d9fb7053c Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 28 Sep 2026 19:35:31 +0000 Subject: [PATCH 26/31] Render event footprint svgs with rsvg-convert to convert them to png to apply --- src/Event/EventRegionSvg.php | 95 +++++++++++++ src/Event/SvgRasterizer.php | 89 ++++++++++++ .../Composite/HelioviewerCompositeImage.php | 129 +++++++++++++----- .../unit_tests/events/EventRegionSvgTest.php | 70 ++++++++++ tests/unit_tests/events/SvgRasterizerTest.php | 53 +++++++ 5 files changed, 399 insertions(+), 37 deletions(-) create mode 100644 src/Event/EventRegionSvg.php create mode 100644 src/Event/SvgRasterizer.php create mode 100644 tests/unit_tests/events/EventRegionSvgTest.php create mode 100644 tests/unit_tests/events/SvgRasterizerTest.php diff --git a/src/Event/EventRegionSvg.php b/src/Event/EventRegionSvg.php new file mode 100644 index 00000000..56653b1f --- /dev/null +++ b/src/Event/EventRegionSvg.php @@ -0,0 +1,95 @@ + + */ +class EventRegionSvg +{ + /** + * @param array $rendered + * Per event: a tint/fill is a list of {x,y} pixel polygons; a ghost is + * {pts: [{x,y}...], closed: bool}. Colour is a 6-hex string, no '#'. + */ + public function __construct( + private array $rendered, + private int $width, + private int $height + ) { + } + + /** The complete SVG document as a string. */ + public function toSvg(): string + { + $maskRegion = 'maskUnits="userSpaceOnUse" x="0" y="0" width="' . $this->width . '" height="' . $this->height . '"'; + $defs = ''; $tintG = ''; $ghostG = ''; $fillG = ''; + + foreach ($this->rendered as $i => $ed) { + $hex = $ed['hex']; + $hasFill = !empty($ed['fills']); + + if ($hasFill) { + // Two luminance masks over the event's own fills: black cuts the tint, + // 45% grey dims the ghost to 0.45 of its strength (0.55 * 0.45 ~= 0.25). + $black = ''; $grey = ''; + foreach ($ed['fills'] as $poly) { + $pp = self::points($poly); + $black .= ''; + $grey .= ''; + } + $defs .= '' . $black . '' + . '' . $grey . ''; + } + + if (!empty($ed['tints'])) { + $in = ''; + foreach ($ed['tints'] as $poly) $in .= ''; + $tintG .= $hasFill ? '' . $in . '' : $in; + } + + if (!empty($ed['ghosts'])) { + $in = ''; + foreach ($ed['ghosts'] as $gs) { + // Closed contour draws as a polygon (needs >=3 points); otherwise a polyline. + $tag = ($gs['closed'] && count($gs['pts']) >= 3) ? 'polygon' : 'polyline'; + $in .= '<' . $tag . ' points="' . self::points($gs['pts']) . '" fill="none" stroke="#' . $hex + . '" stroke-opacity="0.55" stroke-width="1.5" stroke-dasharray="5,4" stroke-linejoin="round" stroke-linecap="round"/>'; + } + $ghostG .= $hasFill ? '' . $in . '' : $in; + } + + if ($hasFill) { + foreach ($ed['fills'] as $poly) { + $fillG .= ''; + } + } + } + + return '' + . '' . $defs . '' + . '' . $tintG . '' // flat tint layer: overlaps stay 0.18 + . '' . $ghostG . '' + . '' . $fillG . '' + . ''; + } + + /** "x,y x,y …" for one polygon/polyline. */ + private static function points(array $poly): string + { + $s = ''; + foreach ($poly as $p) $s .= round($p['x'], 1) . ',' . round($p['y'], 1) . ' '; + return rtrim($s); + } +} diff --git a/src/Event/SvgRasterizer.php b/src/Event/SvgRasterizer.php new file mode 100644 index 00000000..bc54ddaf --- /dev/null +++ b/src/Event/SvgRasterizer.php @@ -0,0 +1,89 @@ + + */ +class SvgRasterizer +{ + /** @var string|null memoized detection for the process (one check per movie run) */ + private static ?string $detected = null; + private static bool $checked = false; + + private ?string $binary; + + /** + * @param string|null $binary explicit path to rsvg-convert (for tests); an + * unusable path reads as unavailable. Auto-detected + * on PATH when null. + */ + public function __construct(?string $binary = null) + { + if ($binary !== null) { + $this->binary = @is_executable($binary) ? $binary : null; + } else { + $this->binary = self::detect(); + } + } + + /** True when an rsvg-convert binary is available. */ + public function isAvailable(): bool + { + return $this->binary !== null; + } + + /** + * Rasterise $svg to a PNG blob at $width x $height. + * + * @throws \RuntimeException when rsvg-convert is missing, cannot be started, + * exits non-zero, or produces no output. The message carries the exit + * code and rsvg's stderr, so a broken render fails loudly and diagnosably. + */ + public function rasterize(string $svg, int $width, int $height): string + { + if ($this->binary === null) { + throw new \RuntimeException('rsvg-convert (librsvg) is required but was not found on PATH.'); + } + $cmd = escapeshellarg($this->binary) . ' -w ' . $width . ' -h ' . $height; + $descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = @proc_open($cmd, $descriptors, $pipes); + if (!is_resource($proc)) { + throw new \RuntimeException('Could not start rsvg-convert (proc_open failed) — is command execution allowed?'); + } + fwrite($pipes[0], $svg); + fclose($pipes[0]); + $png = stream_get_contents($pipes[1]); + $err = stream_get_contents($pipes[2]); // capture stderr for the error message + fclose($pipes[1]); + fclose($pipes[2]); + $rc = proc_close($proc); + if ($rc !== 0 || !is_string($png) || $png === '') { + $why = trim((string) $err); + throw new \RuntimeException(sprintf( + 'rsvg-convert failed to rasterise the SVG (exit %d)%s', + $rc, + $why !== '' ? ': ' . $why : ' — produced no output' + )); + } + return $png; + } + + /** Locate rsvg-convert on PATH, memoized for the process. */ + private static function detect(): ?string + { + if (!self::$checked) { + self::$checked = true; + $path = trim((string) @shell_exec('command -v rsvg-convert 2>/dev/null')); + self::$detected = ($path !== '' && @is_executable($path)) ? $path : null; + } + return self::$detected; + } +} diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index 7a39d6ef..7e3b6bf9 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -21,6 +21,9 @@ use Helioviewer\Api\Sentry\Sentry; use Helioviewer\Api\Event\EventContext; +use Helioviewer\Api\Event\FootprintContour; +use Helioviewer\Api\Event\EventRegionSvg; +use Helioviewer\Api\Event\SvgRasterizer; class Image_Composite_HelioviewerCompositeImage { @@ -639,51 +642,94 @@ private function _addEventLayer($imagickImage) { $events_to_render = $this->eventContext->getEventsForDate($this->date); if (empty($events_to_render)) return; - // Draw event footprint polygons onto the composite image. - // Footprint is a list of rings; each ring is a list of {x, y} points in HPC arcseconds - // (already rotated by Events API). We convert each point from arcseconds to pixel - // coordinates relative to the ROI, then draw one polygon per ring matching the - // frontend SVG style. + // Project an HPC-arcsec {x, y} point to pixel coordinates relative to the ROI: + // px_x = (hpc_x - roi_left) / imageScale - timeOffsetX + // px_y = (-hpc_y - roi_top) / imageScale - timeOffsetY (Y negated: HPC up → pixel down) + $toPixel = function (array $p): array { + return [ + 'x' => (( $p['x'] - $this->roi->left()) / $this->roi->imageScale()) - $this->_timeOffsetX, + 'y' => ((-$p['y'] - $this->roi->top() ) / $this->roi->imageScale()) - $this->_timeOffsetY, + ]; + }; + + // Project a shape's HPC points to pixels. Returns null if any coordinate + // is non-finite (a broken shape we must never hand to ImageMagick — it + // aborts the whole drawImage with a "non-conforming primitive" error). + $project = function (array $points) use ($toPixel): ?array { + $out = []; + foreach ($points as $p) { + $px = $toPixel($p); + if (!is_finite($px['x']) || !is_finite($px['y'])) { + return null; + } + $out[] = $px; + } + return $out; + }; + + // PASS A — regions. The whole footprint layer (far-side tints + dashed + // ghosts and near-side fills, with the per-event own-fill masks) is emitted + // as ONE SVG and rasterised in a single pass by rsvg-convert (librsvg), + // exactly like the web client (reports/region-drawing-spec.html section 9). + // librsvg is REQUIRED: if rsvg-convert is not installed we throw rather than + // degrade. Pins (pass B) then sit above every region. + $canvasW = (int) round($this->width); // svg canvas size; $this->width/height + $canvasH = (int) round($this->height); // can be non-integer floats. + + // Classify each event's rings once and project them to pixels, dropping any + // broken (non-finite / too-few-point) shapes. + $rendered = []; foreach ($events_to_render as $event) { if (empty($event['footprint'])) continue; - - // Match frontend SVG spec: - // - Fill: per-type color (fallback #d4d4d4) at 40% opacity (0x66) - // - Stroke: black at ~53% opacity (0x88), 1.5px, round joins - $fillHex = self::EVENT_COLORS[$event['type'] ?? ''] ?? 'd4d4d4'; - - $draw = new \ImagickDraw(); - $draw->setStrokeLineJoin(\Imagick::LINEJOIN_ROUND); - $draw->setStrokeColor('#00000088'); - $draw->setStrokeWidth(1.5); - $draw->setStrokeAntialias(true); - $draw->setFillColor('#' . $fillHex . '66'); - - // Convert HPC arcseconds to pixel coordinates per ring: - // px_x = (hpc_x - roi_left) / imageScale - timeOffsetX - // px_y = (-hpc_y - roi_top) / imageScale - timeOffsetY (Y negated: HPC up → pixel down) - $anyRingDrawn = false; + $hex = self::EVENT_COLORS[$event['type'] ?? ''] ?? 'd4d4d4'; + $tints = []; $ghosts = []; $fills = []; foreach ($event['footprint'] as $ring) { - $polyArray = []; - foreach ($ring as $point) { - $polyArray[] = [ - 'x' => (( $point['x'] - $this->roi->left()) / $this->roi->imageScale()) - $this->_timeOffsetX, - 'y' => ((-$point['y'] - $this->roi->top() ) / $this->roi->imageScale()) - $this->_timeOffsetY, - ]; + if (count($ring) < 2) continue; + $shapes = (new FootprintContour($ring))->shapes(); + $tints = array_merge($tints, $shapes['tints']); + $ghosts = array_merge($ghosts, $shapes['ghosts']); + $fills = array_merge($fills, $shapes['fills']); + } + $tintPolys = []; + foreach ($tints as $sh) { + $p = $project($sh->points); + if ($p !== null && count($p) >= 3) $tintPolys[] = $p; + } + $ghostShapes = []; + foreach ($ghosts as $sh) { + $p = $project($sh->points); + if ($p !== null && count($p) >= 2) { + $ghostShapes[] = ['pts' => $p, 'closed' => $sh->closed]; } - // Need at least 3 points to form a polygon - if (count($polyArray) < 3) continue; - $draw->polygon($polyArray); - $anyRingDrawn = true; } + $fillPolys = []; + foreach ($fills as $sh) { + $p = $project($sh->points); + if ($p !== null && count($p) >= 3) $fillPolys[] = $p; + } + if (empty($tintPolys) && empty($ghostShapes) && empty($fillPolys)) continue; + $rendered[] = ['hex' => $hex, 'tints' => $tintPolys, 'ghosts' => $ghostShapes, 'fills' => $fillPolys]; + } - if ($anyRingDrawn) { - $imagickImage->drawImage($draw); + // Emit the region layer as one SVG (EventRegionSvg) and rasterise it in a + // single pass with rsvg-convert (SvgRasterizer). librsvg is REQUIRED — if it + // is missing, or the rasterise fails, these throw rather than silently degrade. + if (!empty($rendered)) { + $rasterizer = new SvgRasterizer(); + if (!$rasterizer->isAvailable()) { + throw new \RuntimeException( + 'Event-region rendering requires rsvg-convert (librsvg), which was not found on PATH.' + ); } - $draw->destroy(); + $svg = (new EventRegionSvg($rendered, $canvasW, $canvasH))->toSvg(); + $png = $rasterizer->rasterize($svg, $canvasW, $canvasH); // throws with exit code + stderr on failure + $svgLayer = new \Imagick(); + $svgLayer->readImageBlob($png); + $imagickImage->compositeImage($svgLayer, \Imagick::COMPOSITE_OVER, 0, 0); + $svgLayer->destroy(); } - // Now lay down the event MARKERS + // PASS B — event MARKERS (pins) and labels, above every region. // Cache marker images by resolved path — multiple unknown types share one UNK.png load $markerCache = []; $markerDir = HV_ROOT_DIR . '/resources/images/eventMarkers'; @@ -695,6 +741,14 @@ private function _addEventLayer($imagickImage) { } $marker = clone $markerCache[$path]; + // Far-side event: dim + desaturate the pin (and its label) to match the + // web client (CSS opacity 0.6, grayscale 0.3). Multiply the existing + // alpha channel so the icon's transparency is preserved. + $behind = (($event['visible'] ?? true) === false); + if ($behind) { + $marker->modulateImage(100, 70, 100); // saturation 70% ≈ grayscale(0.3) + $marker->evaluateImage(\Imagick::EVALUATE_MULTIPLY, 0.6, \Imagick::CHANNEL_ALPHA); + } $x = round(( $event['hv_hpc_x'] - $this->roi->left()) / $this->roi->imageScale()); $y = round((-$event['hv_hpc_y'] - $this->roi->top() ) / $this->roi->imageScale()); @@ -725,7 +779,7 @@ private function _addEventLayer($imagickImage) { $text->setStrokeColor('#000C'); $text->setStrokeAntialias(true); $text->setStrokeWidth(3); - $text->setStrokeOpacity(0.3); + $text->setStrokeOpacity($behind ? 0.18 : 0.3); $imagickImage->annotateImage($text, $x, $y+($count*12), 0, $value ); // Write words in white over outline @@ -734,6 +788,7 @@ private function _addEventLayer($imagickImage) { $text->setFont(HV_ROOT_DIR.'/../resources/fonts/DejaVuSans.ttf'); $text->setFontSize(10); $text->setFillColor('#ffff'); + if ($behind) $text->setFillOpacity(0.6); $text->setTextAntialias(true); $text->setStrokeWidth(0); $imagickImage->annotateImage($text, $x, $y+($count*12), 0, $value ); diff --git a/tests/unit_tests/events/EventRegionSvgTest.php b/tests/unit_tests/events/EventRegionSvgTest.php new file mode 100644 index 00000000..760d4e51 --- /dev/null +++ b/tests/unit_tests/events/EventRegionSvgTest.php @@ -0,0 +1,70 @@ + + */ + +use PHPUnit\Framework\TestCase; +use Helioviewer\Api\Event\EventRegionSvg; + +final class EventRegionSvgTest extends TestCase +{ + /** A 3-point pixel polygon. */ + private function tri(): array + { + return [['x' => 0.0, 'y' => 0.0], ['x' => 10.0, 'y' => 0.0], ['x' => 10.0, 'y' => 10.0]]; + } + + public function testEmptyProducesTheThreeGroupsAtTheRightSize(): void + { + $svg = (new EventRegionSvg([], 100, 80))->toSvg(); + $this->assertStringStartsWith('assertStringContainsString('width="100"', $svg); + $this->assertStringContainsString('height="80"', $svg); + $this->assertStringContainsString('', $svg); // the flat tint layer + $this->assertStringNotContainsString(' 'FEF38E', + 'tints' => [$this->tri()], + 'ghosts' => [['pts' => $this->tri(), 'closed' => false]], + 'fills' => [$this->tri()], + ]]; + $svg = (new EventRegionSvg($rendered, 200, 150))->toSvg(); + + // per-event own-fill masks: black cuts the tint, 45% grey dims the ghost + $this->assertStringContainsString('assertStringContainsString('assertStringContainsString('fill="#737373"', $svg); + // the tint sits in the flat 0.18 group and is masked + $this->assertStringContainsString('', $svg); + $this->assertStringContainsString('mask="url(#gd0)"', $svg); + // an open ghost run -> polyline, dashed @ 0.55 + $this->assertStringContainsString('assertStringContainsString('stroke-opacity="0.55"', $svg); + $this->assertStringContainsString('stroke-dasharray="5,4"', $svg); + // fill @0.4 with black stroke @0.533, in the event colour + $this->assertStringContainsString('fill-opacity="0.4"', $svg); + $this->assertStringContainsString('stroke-opacity="0.533"', $svg); + $this->assertStringContainsString('fill="#FEF38E"', $svg); + } + + public function testEventWithoutFillsHasNoMasksAndClosedGhostIsPolygon(): void + { + // all-behind event: a tint + a closed ghost, no near-side fills → no masks. + $rendered = [[ + 'hex' => 'B0C4FF', + 'tints' => [$this->tri()], + 'ghosts' => [['pts' => $this->tri(), 'closed' => true]], + 'fills' => [], + ]]; + $svg = (new EventRegionSvg($rendered, 100, 100))->toSvg(); + + $this->assertStringNotContainsString('assertStringNotContainsString('mask="url', $svg); + $this->assertStringContainsString('=3 pts) -> polygon + } +} diff --git a/tests/unit_tests/events/SvgRasterizerTest.php b/tests/unit_tests/events/SvgRasterizerTest.php new file mode 100644 index 00000000..fc2edafb --- /dev/null +++ b/tests/unit_tests/events/SvgRasterizerTest.php @@ -0,0 +1,53 @@ + + */ + +use PHPUnit\Framework\TestCase; +use Helioviewer\Api\Event\SvgRasterizer; + +final class SvgRasterizerTest extends TestCase +{ + private function svg(int $w, int $h): string + { + return '' + . ''; + } + + private static function rsvgInstalled(): bool + { + return trim((string) @shell_exec('command -v rsvg-convert 2>/dev/null')) !== ''; + } + + public function testUnusableBinaryIsNotAvailableAndRasteriseThrows(): void + { + $r = new SvgRasterizer('/nonexistent/rsvg-convert-xyz'); + $this->assertFalse($r->isAvailable()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/not found on PATH/'); + $r->rasterize($this->svg(10, 10), 10, 10); + } + + public function testValidSvgRasterisesToPng(): void + { + if (!self::rsvgInstalled()) { $this->markTestSkipped('rsvg-convert not installed'); } + + $r = new SvgRasterizer(); + $this->assertTrue($r->isAvailable()); + + $png = $r->rasterize($this->svg(12, 12), 12, 12); + $this->assertSame("\x89PNG\r\n\x1a\n", substr($png, 0, 8)); // PNG magic number + } + + public function testBrokenSvgThrowsWithExitCodeAndStderr(): void + { + if (!self::rsvgInstalled()) { $this->markTestSkipped('rsvg-convert not installed'); } + + $r = new SvgRasterizer(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/rsvg-convert failed.*exit \d/i'); + $r->rasterize('THIS IS NOT SVG <<<', 10, 10); + } +} From 6445d1415a886565e3cf8d1941394622c63a345b Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 29 Sep 2026 16:01:24 +0000 Subject: [PATCH 27/31] implement configurable table names for client_states, movies, screenshots --- settings/Config.Example.ini | 10 ++++++++++ src/Config.php | 13 +++++++++++++ src/Database/ClientState.php | 10 +++++----- src/Database/ImgIndex.php | 12 ++++++------ src/Database/MovieDatabase.php | 10 +++++----- src/Database/Statistics.php | 4 ++-- tests/unit_tests/database/ClientStateTest.php | 6 +++--- 7 files changed, 44 insertions(+), 21 deletions(-) diff --git a/settings/Config.Example.ini b/settings/Config.Example.ini index 97035bb9..823d6be2 100644 --- a/settings/Config.Example.ini +++ b/settings/Config.Example.ini @@ -135,6 +135,16 @@ ffmpeg_max_threads = 8 ; Movie generation queue movie_queue = "api_movie" +[database] +; Table names for the movies, screenshots, and client_states tables. Override +; these if those tables live under different names in your database (e.g. a +; shared database, or a per-instance naming scheme). Defaults shown; leaving +; them unset keeps the canonical names, so no change is required for a normal +; install. Every query in the API resolves the table through these values. +db_table_movies = "movies" +db_table_screenshots = "screenshots" +db_table_client_states = "client_states" + [statistics] ; Whether basic usage statistics should be collected by Helioviewer.org. ; Currently the information collected is very basic and does not include diff --git a/src/Config.php b/src/Config.php index e67c46b0..e5b6ceab 100644 --- a/src/Config.php +++ b/src/Config.php @@ -121,6 +121,19 @@ private function _setAdditionalParams() { define('HV_LOG_DIR', HV_ROOT_DIR . '/../log'); define('HV_CACHE_URL', HV_WEB_ROOT_URL . '/cache'); + // Database table names. Overridable from Config.ini (db_table_movies, + // db_table_screenshots, db_table_client_states); default to the + // canonical names so existing deployments need no config change. + if ( !defined('HV_DB_TABLE_MOVIES') ) { + define('HV_DB_TABLE_MOVIES', 'movies'); + } + if ( !defined('HV_DB_TABLE_SCREENSHOTS') ) { + define('HV_DB_TABLE_SCREENSHOTS', 'screenshots'); + } + if ( !defined('HV_DB_TABLE_CLIENT_STATES') ) { + define('HV_DB_TABLE_CLIENT_STATES', 'client_states'); + } + // 1 au in meters (http://maia.usno.navy.mil/NSFA/IAU2009_consts.html) define('HV_CONSTANT_AU', 149597870700); // Solar radius in arc-seconds at 1 au diff --git a/src/Database/ClientState.php b/src/Database/ClientState.php index dfac0314..701ddefc 100644 --- a/src/Database/ClientState.php +++ b/src/Database/ClientState.php @@ -24,7 +24,7 @@ public function upsert(array $state): string $state_json = json_encode($state); $state_key = hash('sha256',$state_json); - $create_sql = "REPLACE INTO client_states(id, state) VALUES ('%s','%s')"; + $create_sql = "REPLACE INTO " . HV_DB_TABLE_CLIENT_STATES . "(id, state) VALUES ('%s','%s')"; $create_state_sql = sprintf($create_sql, $state_key, $this->link->real_escape_string($state_json)); // intentionally let exception thrown @@ -40,7 +40,7 @@ public function upsert(array $state): string */ public function find(string $state_key): ?array { - $find_sql = "SELECT * FROM client_states WHERE id = '%s' LIMIT 1"; + $find_sql = "SELECT * FROM " . HV_DB_TABLE_CLIENT_STATES . " WHERE id = '%s' LIMIT 1"; $find_state_sql = sprintf($find_sql, $this->link->real_escape_string($state_key)); @@ -66,7 +66,7 @@ public function find(string $state_key): ?array */ public function all(int $limit = 100): ?array { - $all_sql = "SELECT * FROM client_states LIMIT $limit"; + $all_sql = "SELECT * FROM " . HV_DB_TABLE_CLIENT_STATES . " LIMIT $limit"; $query_result = $this->query($all_sql); @@ -85,7 +85,7 @@ public function all(int $limit = 100): ?array */ public function update(string $state_key, array $state): bool { - $find_sql = "SELECT * FROM client_states WHERE id = '%s' LIMIT 1"; + $find_sql = "SELECT * FROM " . HV_DB_TABLE_CLIENT_STATES . " WHERE id = '%s' LIMIT 1"; $find_state_sql = sprintf($find_sql, $this->link->real_escape_string($state_key)); @@ -96,7 +96,7 @@ public function update(string $state_key, array $state): bool return false; } - $update_sql = "UPDATE client_states SET state = '%s' WHERE id = '%s'"; + $update_sql = "UPDATE " . HV_DB_TABLE_CLIENT_STATES . " SET state = '%s' WHERE id = '%s'"; $update_state_sql = sprintf($update_sql, $this->link->real_escape_string(json_encode($state)), $this->link->real_escape_string($state_key)); diff --git a/src/Database/ImgIndex.php b/src/Database/ImgIndex.php index 51b7562b..52ab95ab 100644 --- a/src/Database/ImgIndex.php +++ b/src/Database/ImgIndex.php @@ -73,7 +73,7 @@ public function insertScreenshot($date, $imageScale, $roi, $watermark, $layers, $sql = sprintf( - "INSERT INTO screenshots " + "INSERT INTO " . HV_DB_TABLE_SCREENSHOTS . " " . "SET " . "id " . " = NULL, " . "timestamp " . " = CURRENT_TIMESTAMP, " @@ -137,7 +137,7 @@ public function getMovieInformation($movieId) { $sql = sprintf( "SELECT movies.*, movieFormats.movieId, movieFormats.format, movieFormats.status, movieFormats.procTime, movieFormats.modified, ST_AsText(regionOfInterest) AS roi " - . "FROM movies " + . "FROM " . HV_DB_TABLE_MOVIES . " movies " . "LEFT JOIN " . "movieFormats ON movies.id = movieFormats.movieId " . "WHERE " @@ -169,7 +169,7 @@ public function storeMovieProperties($movieId, $startDate, $endDate, $this->_dbConnect(); $sql = sprintf( - "UPDATE movies " + "UPDATE " . HV_DB_TABLE_MOVIES . " " . "SET " . "startDate " . " ='%s', " . "endDate " . " ='%s', " @@ -217,7 +217,7 @@ public function finishedBuildingMovieFrames($movieId, $buildTimeStart, $this->_dbConnect(); $sql = sprintf( - "UPDATE movies " + "UPDATE " . HV_DB_TABLE_MOVIES . " " . "SET " . "buildTimeStart='%s', " . "buildTimeEnd='%s' " @@ -350,7 +350,7 @@ public function getScreenshot($screenshotId) { $sql = sprintf( "SELECT * " - . "FROM screenshots " + . "FROM " . HV_DB_TABLE_SCREENSHOTS . " " . "WHERE id = %d " . "LIMIT 1;", (int)$screenshotId @@ -1631,7 +1631,7 @@ public function getScreenshotMetadata($screenshotId) { $this->_dbConnect(); $sql = sprintf('SELECT *, ST_AsText(regionOfInterest) as roi ' . - 'FROM screenshots WHERE id=%d LIMIT 1;', + 'FROM ' . HV_DB_TABLE_SCREENSHOTS . ' WHERE id=%d LIMIT 1;', (int)$screenshotId ); try { diff --git a/src/Database/MovieDatabase.php b/src/Database/MovieDatabase.php index a6c1c594..127ad7ba 100644 --- a/src/Database/MovieDatabase.php +++ b/src/Database/MovieDatabase.php @@ -84,7 +84,7 @@ public function insertMovie($startTime, $endTime, $reqObservationDate, $imageSca $old_events_labels_bool = false; $sql = sprintf( - 'INSERT INTO movies ' + 'INSERT INTO ' . HV_DB_TABLE_MOVIES . ' ' . 'SET ' . 'id ' . ' = NULL, ' . 'timestamp ' . ' = CURRENT_TIMESTAMP, ' @@ -282,7 +282,7 @@ public function getMovieStatistics($n=100) { . 'queueNum, ' . 'TIMESTAMPDIFF(SECOND, buildTimeStart, buildTimeEnd) ' . 'AS time ' - . 'FROM movies ' + . 'FROM ' . HV_DB_TABLE_MOVIES . ' ' . 'WHERE ' . 'TIMESTAMPDIFF(SECOND, buildTimeStart, buildTimeEnd) ' . '> 0 ' @@ -360,7 +360,7 @@ public function getSharedVideos($num, $skip, $since, $force=false) { . 'youtube.keywords, youtube.shared, movies.imageScale, movies.dataSourceString, movies.eventSourceString, ' . 'movies.movieLength, movies.width, movies.height, movies.startDate, movies.endDate ' . 'FROM youtube ' - . 'LEFT JOIN movies ' + . 'LEFT JOIN ' . HV_DB_TABLE_MOVIES . ' movies ' . 'ON movies.id = youtube.movieId ' . 'WHERE ' . 'youtube.shared>0 AND ' @@ -427,7 +427,7 @@ public function getSharedVideosByTime($num, $skip, $date) { . 'youtube.keywords, youtube.thumbnail, youtube.shared, youtube.checked, movies.imageScale, movies.dataSourceString, movies.eventSourceString, ' . 'movies.movieLength, movies.width, movies.height, movies.startDate, movies.endDate, ST_AsText(regionOfInterest) as roi ' . 'FROM youtube ' - . 'LEFT JOIN movies ' + . 'LEFT JOIN ' . HV_DB_TABLE_MOVIES . ' movies ' . 'ON movies.id = youtube.movieId ' . 'WHERE ' . 'youtube.shared>0 AND ' @@ -493,7 +493,7 @@ public function getMovieMetadata($movieId) { $this->_dbConnect(); $sql = sprintf('SELECT *, ST_AsText(regionOfInterest) as roi ' - . 'FROM movies WHERE movies.id=%d LIMIT 1;', + . 'FROM ' . HV_DB_TABLE_MOVIES . ' movies WHERE movies.id=%d LIMIT 1;', (int)$movieId ); try { diff --git a/src/Database/Statistics.php b/src/Database/Statistics.php index a105dad6..77d712a6 100644 --- a/src/Database/Statistics.php +++ b/src/Database/Statistics.php @@ -603,7 +603,7 @@ public function getUsageStatistics($resolution, $dateStart = null, $dateEnd = nu $sqlScreenshots = sprintf( "SELECT dataSourceString " - . "FROM movies " + . "FROM " . HV_DB_TABLE_MOVIES . " " . "WHERE " . "timestamp BETWEEN '%s' AND '%s' ;", $this->_dbConnection->link->real_escape_string($dateStartStr), @@ -655,7 +655,7 @@ public function getUsageStatistics($resolution, $dateStart = null, $dateEnd = nu $sqlScreenshots = sprintf( "SELECT dataSourceString " - . "FROM screenshots " + . "FROM " . HV_DB_TABLE_SCREENSHOTS . " " . "WHERE " . "timestamp BETWEEN '%s' AND '%s' ;", $this->_dbConnection->link->real_escape_string($dateStartStr), diff --git a/tests/unit_tests/database/ClientStateTest.php b/tests/unit_tests/database/ClientStateTest.php index 95319745..27ee0329 100644 --- a/tests/unit_tests/database/ClientStateTest.php +++ b/tests/unit_tests/database/ClientStateTest.php @@ -27,7 +27,7 @@ public function testItShouldCreateClientState(): void // just a precaution to clean database after we are done $this->createdIds[] = $client_state->upsert($state); - $count_sql = sprintf("SELECT COUNT(*) FROM client_states WHERE id = '%s'", hash('sha256', json_encode($state))); + $count_sql = sprintf("SELECT COUNT(*) FROM " . HV_DB_TABLE_CLIENT_STATES . " WHERE id = '%s'", hash('sha256', json_encode($state))); $result = $client_state->query($count_sql); @@ -49,7 +49,7 @@ public function testItShouldOnlyCreateDBInstancePerClientState(): void $client_state->upsert($state); $client_state->upsert($state); - $count_sql = sprintf("SELECT COUNT(*) FROM client_states WHERE id = '%s'", hash('sha256', json_encode($state))); + $count_sql = sprintf("SELECT COUNT(*) FROM " . HV_DB_TABLE_CLIENT_STATES . " WHERE id = '%s'", hash('sha256', json_encode($state))); $result = $client_state->query($count_sql); @@ -103,7 +103,7 @@ public function testItShouldNotFindNonExistingStates(): void public function tearDown(): void { $client_state = new ClientState(); - $client_state->query(sprintf("DELETE FROM client_states WHERE id in ('%s') LIMIT %d", join("','", $this->createdIds), count($this->createdIds))); + $client_state->query(sprintf("DELETE FROM " . HV_DB_TABLE_CLIENT_STATES . " WHERE id in ('%s') LIMIT %d", join("','", $this->createdIds), count($this->createdIds))); } From 6b54b8b11f956631f8784d10de76cd02a92ea392 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 29 Sep 2026 16:37:00 +0000 Subject: [PATCH 28/31] use configurable table names for wsa migrator scripts as well --- management/events/migrate_movies_events_shape.php | 4 ++-- management/events/migrate_screenshots_events_shape.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/management/events/migrate_movies_events_shape.php b/management/events/migrate_movies_events_shape.php index cc0596f2..2d190566 100644 --- a/management/events/migrate_movies_events_shape.php +++ b/management/events/migrate_movies_events_shape.php @@ -31,7 +31,7 @@ : "MODE: DRY-RUN — no writes; re-run with --apply to write\n\n"; $db = new Database_DbConnection(); -$res = $db->query("SELECT id, eventsState FROM movies"); +$res = $db->query("SELECT id, eventsState FROM " . HV_DB_TABLE_MOVIES); $migrated = 0; $skipped = 0; @@ -53,7 +53,7 @@ if ($apply) { $new_blob = json_encode($shape); - $stmt = $db->link->prepare("UPDATE movies SET eventsState = ? WHERE id = ?"); + $stmt = $db->link->prepare("UPDATE " . HV_DB_TABLE_MOVIES . " SET eventsState = ? WHERE id = ?"); $stmt->bind_param('si', $new_blob, $row['id']); $stmt->execute(); $stmt->close(); diff --git a/management/events/migrate_screenshots_events_shape.php b/management/events/migrate_screenshots_events_shape.php index 31810c52..773bcaf9 100644 --- a/management/events/migrate_screenshots_events_shape.php +++ b/management/events/migrate_screenshots_events_shape.php @@ -30,7 +30,7 @@ : "MODE: DRY-RUN — no writes; re-run with --apply to write\n\n"; $db = new Database_DbConnection(); -$res = $db->query("SELECT id, eventsState FROM screenshots"); +$res = $db->query("SELECT id, eventsState FROM " . HV_DB_TABLE_SCREENSHOTS); $migrated = 0; $skipped = 0; @@ -52,7 +52,7 @@ if ($apply) { $new_blob = json_encode($shape); - $stmt = $db->link->prepare("UPDATE screenshots SET eventsState = ? WHERE id = ?"); + $stmt = $db->link->prepare("UPDATE " . HV_DB_TABLE_SCREENSHOTS . " SET eventsState = ? WHERE id = ?"); $stmt->bind_param('si', $new_blob, $row['id']); $stmt->execute(); $stmt->close(); From 65e7c30cd59335e9f66aa3265c444eee6cd0b6f2 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 29 Sep 2026 18:49:09 +0000 Subject: [PATCH 29/31] wsa migration / more efficient updte of movies and screenshots --- management/events/events_shape.php | 113 ++++++++++++++++++ .../events/migrate_movies_events_shape.php | 44 ++----- .../migrate_screenshots_events_shape.php | 44 ++----- 3 files changed, 137 insertions(+), 64 deletions(-) diff --git a/management/events/events_shape.php b/management/events/events_shape.php index 42417a69..ac594baf 100644 --- a/management/events/events_shape.php +++ b/management/events/events_shape.php @@ -106,3 +106,116 @@ function events_migration_print_unique(array $bucket, string $table): void } echo count($bucket) . " unique conversion(s)\n"; } + +/** + * Read a --batch=N argument (rows per page); defaults to 10000. + * + * @param array $argv the script's $argv + * @param int $default rows per page when --batch is absent + * @return int a positive batch size + */ +function events_migration_batch_size(array $argv, int $default = 10000): int +{ + foreach ($argv as $arg) { + if (preg_match('/^--batch=(\d+)$/', $arg, $m)) { + return max(1, (int)$m[1]); + } + } + return $default; +} + +/** + * Migrate one events-state table (movies / screenshots) to the canonical shape + * in keyset-paginated batches. Per page it runs ONE SELECT (10k rows ordered by + * id) and, when $apply, ONE `UPDATE ... SET eventsState = CASE id ... END WHERE + * id IN (...)` — so a table of N rows costs ~2*ceil(N/$batchSize) queries rather + * than one UPDATE per row. + * + * The id primary key is never written (a CASE UPDATE can only touch existing + * rows — it cannot insert, so the NOT NULL/no-default columns are irrelevant), + * rows already carrying event_selections are skipped (idempotent/resumable), + * and each page's write is wrapped in a transaction. Unique tree=>shape + * conversions accumulate into $conversions for the dry-run report. + * + * @param Database_DbConnection $db + * @param string $table resolved table name (an HV_DB_TABLE_* constant) + * @param bool $apply false = dry-run (reads + counts, writes nothing) + * @param int $batchSize rows per page + * @param int $migrated out (by ref): rows converted / would-convert + * @param int $skipped out (by ref): rows already new-shape + * @param array $conversions out (by ref): unique conversions for the report + */ +function events_migrate_table( + Database_DbConnection $db, + string $table, + bool $apply, + int $batchSize, + int &$migrated, + int &$skipped, + array &$conversions +): void { + $lastId = 0; + do { + // One SELECT per page — keyset on the PK (no OFFSET rescans). + $sql = sprintf( + 'SELECT id, eventsState FROM %s WHERE id > %d ORDER BY id LIMIT %d', + $table, $lastId, $batchSize + ); + $res = $db->query($sql); + $rows = $res->fetch_all(MYSQLI_ASSOC); + $res->close(); + + $updates = []; // id => new eventsState blob, for this page's single UPDATE + foreach ($rows as $row) { + $lastId = (int)$row['id']; // advance keyset by the max id examined + + $tree = json_decode($row['eventsState'] ?? '', true); + if (!is_array($tree)) { + $tree = []; + } + + // Already new-shape — leave it alone (idempotency / resume). + if (isset($tree['event_selections'])) { + $skipped++; + continue; + } + + $shape = events_shape_from_tree($tree); + events_migration_collect($conversions, "#{$row['id']}", $tree, $shape); + $updates[(int)$row['id']] = json_encode($shape); + $migrated++; + } + + if ($apply && $updates) { + events_apply_eventsstate_batch($db, $table, $updates); + } + } while (count($rows) === $batchSize); +} + +/** + * Write one page of {id => eventsState} back with a single CASE UPDATE, in a + * transaction. Values are escaped (ids cast to int, blobs real_escape_string'd); + * the table name is a trusted HV_DB_TABLE_* constant, never user input. + * + * @param Database_DbConnection $db + * @param string $table + * @param array $updates id => eventsState JSON blob + */ +function events_apply_eventsstate_batch(Database_DbConnection $db, string $table, array $updates): void +{ + $case = ''; + foreach ($updates as $id => $blob) { + $case .= sprintf(" WHEN %d THEN '%s'", (int)$id, $db->link->real_escape_string($blob)); + } + $ids = implode(',', array_map('intval', array_keys($updates))); + $sql = sprintf('UPDATE %s SET eventsState = CASE id%s END WHERE id IN (%s)', $table, $case, $ids); + + $db->link->begin_transaction(); + try { + $db->query($sql); + $db->link->commit(); + } catch (\Throwable $e) { + $db->link->rollback(); + throw $e; + } +} diff --git a/management/events/migrate_movies_events_shape.php b/management/events/migrate_movies_events_shape.php index 2d190566..f3f40f08 100644 --- a/management/events/migrate_movies_events_shape.php +++ b/management/events/migrate_movies_events_shape.php @@ -11,13 +11,17 @@ * row it would migrate, so the mapping can be validated before committing. * - Idempotent + resumable: rows already carrying event_selections are skipped. * - The primary key (auto-inc id) is never touched. + * - Keyset-paginated: reads in batches of --batch rows (default 10000) and + * writes each batch with ONE `CASE` UPDATE, so a table of N rows migrates in + * ~2*ceil(N/batch) queries instead of one UPDATE per row. * - Runs inside the maintenance window, after the new code is deployed. * * Retired in the post-deploy cleanup (with EventsStateManager). * * Usage: - * php management/events/migrate_movies_events_shape.php # dry-run - * php management/events/migrate_movies_events_shape.php --apply # write + * php management/events/migrate_movies_events_shape.php # dry-run + * php management/events/migrate_movies_events_shape.php --apply # write + * php management/events/migrate_movies_events_shape.php --apply --batch=5000 */ require_once sprintf('%s/../../vendor/autoload.php', __DIR__); @@ -25,42 +29,18 @@ require_once sprintf('%s/../../src/Database/DbConnection.php', __DIR__); require_once sprintf('%s/events_shape.php', __DIR__); -$apply = in_array('--apply', $argv, true); +$apply = in_array('--apply', $argv, true); +$batchSize = events_migration_batch_size($argv); // --batch=N (default 10000) echo $apply - ? "MODE: APPLY — writing changes to movies\n\n" - : "MODE: DRY-RUN — no writes; re-run with --apply to write\n\n"; + ? "MODE: APPLY — writing changes to movies (batch $batchSize)\n\n" + : "MODE: DRY-RUN — no writes; re-run with --apply to write (batch $batchSize)\n\n"; -$db = new Database_DbConnection(); -$res = $db->query("SELECT id, eventsState FROM " . HV_DB_TABLE_MOVIES); +$db = new Database_DbConnection(); $migrated = 0; $skipped = 0; $conversions = []; -while ($row = $res->fetch_assoc()) { - $tree = json_decode($row['eventsState'] ?? '', true); - if (!is_array($tree)) { - $tree = []; - } - - // Already new-shape — leave it alone (idempotency / resume). - if (isset($tree['event_selections'])) { - $skipped++; - continue; - } - - $shape = events_shape_from_tree($tree); - events_migration_collect($conversions, "#{$row['id']}", $tree, $shape); - - if ($apply) { - $new_blob = json_encode($shape); - $stmt = $db->link->prepare("UPDATE " . HV_DB_TABLE_MOVIES . " SET eventsState = ? WHERE id = ?"); - $stmt->bind_param('si', $new_blob, $row['id']); - $stmt->execute(); - $stmt->close(); - } - $migrated++; -} -$res->close(); +events_migrate_table($db, HV_DB_TABLE_MOVIES, $apply, $batchSize, $migrated, $skipped, $conversions); events_migration_print_unique($conversions, 'movies'); diff --git a/management/events/migrate_screenshots_events_shape.php b/management/events/migrate_screenshots_events_shape.php index 773bcaf9..f1f33f28 100644 --- a/management/events/migrate_screenshots_events_shape.php +++ b/management/events/migrate_screenshots_events_shape.php @@ -10,13 +10,17 @@ * - Idempotent + resumable: rows already carrying event_selections are * skipped, so the script is safe to re-run and to interrupt. * - The primary key (auto-inc id) is never touched. + * - Keyset-paginated: reads in batches of --batch rows (default 10000) and + * writes each batch with ONE `CASE` UPDATE, so a table of N rows migrates in + * ~2*ceil(N/batch) queries instead of one UPDATE per row. * - Runs inside the maintenance window, after the new code is deployed. * * Retired in the post-deploy cleanup (with EventsStateManager). * * Usage: - * php management/events/migrate_screenshots_events_shape.php # dry-run - * php management/events/migrate_screenshots_events_shape.php --apply # write + * php management/events/migrate_screenshots_events_shape.php # dry-run + * php management/events/migrate_screenshots_events_shape.php --apply # write + * php management/events/migrate_screenshots_events_shape.php --apply --batch=5000 */ require_once sprintf('%s/../../vendor/autoload.php', __DIR__); @@ -24,42 +28,18 @@ require_once sprintf('%s/../../src/Database/DbConnection.php', __DIR__); require_once sprintf('%s/events_shape.php', __DIR__); -$apply = in_array('--apply', $argv, true); +$apply = in_array('--apply', $argv, true); +$batchSize = events_migration_batch_size($argv); // --batch=N (default 10000) echo $apply - ? "MODE: APPLY — writing changes to screenshots\n\n" - : "MODE: DRY-RUN — no writes; re-run with --apply to write\n\n"; + ? "MODE: APPLY — writing changes to screenshots (batch $batchSize)\n\n" + : "MODE: DRY-RUN — no writes; re-run with --apply to write (batch $batchSize)\n\n"; -$db = new Database_DbConnection(); -$res = $db->query("SELECT id, eventsState FROM " . HV_DB_TABLE_SCREENSHOTS); +$db = new Database_DbConnection(); $migrated = 0; $skipped = 0; $conversions = []; -while ($row = $res->fetch_assoc()) { - $tree = json_decode($row['eventsState'] ?? '', true); - if (!is_array($tree)) { - $tree = []; - } - - // Already new-shape — leave it alone (idempotency / resume). - if (isset($tree['event_selections'])) { - $skipped++; - continue; - } - - $shape = events_shape_from_tree($tree); - events_migration_collect($conversions, "#{$row['id']}", $tree, $shape); - - if ($apply) { - $new_blob = json_encode($shape); - $stmt = $db->link->prepare("UPDATE " . HV_DB_TABLE_SCREENSHOTS . " SET eventsState = ? WHERE id = ?"); - $stmt->bind_param('si', $new_blob, $row['id']); - $stmt->execute(); - $stmt->close(); - } - $migrated++; -} -$res->close(); +events_migrate_table($db, HV_DB_TABLE_SCREENSHOTS, $apply, $batchSize, $migrated, $skipped, $conversions); events_migration_print_unique($conversions, 'screenshots'); From 5880579be1cd6b60839b05b716c1589819b12cff Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 29 Sep 2026 18:54:58 +0000 Subject: [PATCH 30/31] add progress info to miugration scripts --- management/events/events_shape.php | 40 +++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/management/events/events_shape.php b/management/events/events_shape.php index ac594baf..65ee817d 100644 --- a/management/events/events_shape.php +++ b/management/events/events_shape.php @@ -154,8 +154,18 @@ function events_migrate_table( int &$skipped, array &$conversions ): void { - $lastId = 0; + // Upper bound for the % readout (PK max — one cheap, indexed query). + $maxRes = $db->query(sprintf('SELECT MAX(id) AS m FROM %s', $table)); + $maxRow = $maxRes->fetch_assoc(); + $maxRes->close(); + $maxId = (int)($maxRow['m'] ?? 0); + + $started = microtime(true); + $page = 0; + $lastId = 0; do { + $page++; + // One SELECT per page — keyset on the PK (no OFFSET rescans). $sql = sprintf( 'SELECT id, eventsState FROM %s WHERE id > %d ORDER BY id LIMIT %d', @@ -189,9 +199,37 @@ function events_migrate_table( if ($apply && $updates) { events_apply_eventsstate_batch($db, $table, $updates); } + + events_migration_progress($table, $page, $lastId, $maxId, $migrated, $skipped, $started); } while (count($rows) === $batchSize); } +/** + * Print one progress line to STDERR after each page — so a long run shows life + * without polluting the STDOUT report/summary. Shows the keyset position + * (id reached / max, %), the running migrated & skipped counts, elapsed time, + * and throughput. + */ +function events_migration_progress( + string $table, + int $page, + int $lastId, + int $maxId, + int $migrated, + int $skipped, + float $started +): void { + $elapsed = microtime(true) - $started; + $scanned = $migrated + $skipped; + $rate = $elapsed > 0 ? (int)round($scanned / $elapsed) : 0; + $pct = $maxId > 0 ? min(100, (int)round($lastId / $maxId * 100)) : 100; + + fwrite(STDERR, sprintf( + " [%s] page %d — id %d/%d (%d%%) · scanned %d · migrated %d · skipped %d · %.1fs · %d rows/s\n", + $table, $page, $lastId, $maxId, $pct, $scanned, $migrated, $skipped, $elapsed, $rate + )); +} + /** * Write one page of {id => eventsState} back with a single CASE UPDATE, in a * transaction. Values are escaped (ids cast to int, blobs real_escape_string'd); From 292fecb35e8a813e5b23e0017b4b7b96caa39056 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 29 Sep 2026 19:32:27 +0000 Subject: [PATCH 31/31] add start-id to use it after deployment to migrate ids larger than given option --- management/events/events_shape.php | 22 ++++++++++++++++++- .../events/migrate_movies_events_shape.php | 11 +++++++--- .../migrate_screenshots_events_shape.php | 11 +++++++--- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/management/events/events_shape.php b/management/events/events_shape.php index 65ee817d..a0992f71 100644 --- a/management/events/events_shape.php +++ b/management/events/events_shape.php @@ -124,6 +124,24 @@ function events_migration_batch_size(array $argv, int $default = 10000): int return $default; } +/** + * Read a --start-id=N argument: migrate only rows with id > N. Defaults to 0 + * (whole table). Lets a huge migration start past an id already done, or resume + * after an interruption from the last id it reported. + * + * @param array $argv the script's $argv + * @return int a non-negative starting id (exclusive lower bound) + */ +function events_migration_start_id(array $argv): int +{ + foreach ($argv as $arg) { + if (preg_match('/^--start-id=(\d+)$/', $arg, $m)) { + return max(0, (int)$m[1]); + } + } + return 0; +} + /** * Migrate one events-state table (movies / screenshots) to the canonical shape * in keyset-paginated batches. Per page it runs ONE SELECT (10k rows ordered by @@ -141,6 +159,7 @@ function events_migration_batch_size(array $argv, int $default = 10000): int * @param string $table resolved table name (an HV_DB_TABLE_* constant) * @param bool $apply false = dry-run (reads + counts, writes nothing) * @param int $batchSize rows per page + * @param int $startId migrate only rows with id > this (0 = whole table) * @param int $migrated out (by ref): rows converted / would-convert * @param int $skipped out (by ref): rows already new-shape * @param array $conversions out (by ref): unique conversions for the report @@ -150,6 +169,7 @@ function events_migrate_table( string $table, bool $apply, int $batchSize, + int $startId, int &$migrated, int &$skipped, array &$conversions @@ -162,7 +182,7 @@ function events_migrate_table( $started = microtime(true); $page = 0; - $lastId = 0; + $lastId = $startId; // keyset starts here — only rows with id > startId are touched do { $page++; diff --git a/management/events/migrate_movies_events_shape.php b/management/events/migrate_movies_events_shape.php index f3f40f08..3666aa43 100644 --- a/management/events/migrate_movies_events_shape.php +++ b/management/events/migrate_movies_events_shape.php @@ -14,6 +14,8 @@ * - Keyset-paginated: reads in batches of --batch rows (default 10000) and * writes each batch with ONE `CASE` UPDATE, so a table of N rows migrates in * ~2*ceil(N/batch) queries instead of one UPDATE per row. + * - --start-id=N migrates only rows with id > N — resume a huge run, or skip + * an already-done id range. * - Runs inside the maintenance window, after the new code is deployed. * * Retired in the post-deploy cleanup (with EventsStateManager). @@ -22,6 +24,7 @@ * php management/events/migrate_movies_events_shape.php # dry-run * php management/events/migrate_movies_events_shape.php --apply # write * php management/events/migrate_movies_events_shape.php --apply --batch=5000 + * php management/events/migrate_movies_events_shape.php --apply --start-id=1000000 */ require_once sprintf('%s/../../vendor/autoload.php', __DIR__); @@ -31,16 +34,18 @@ $apply = in_array('--apply', $argv, true); $batchSize = events_migration_batch_size($argv); // --batch=N (default 10000) +$startId = events_migration_start_id($argv); // --start-id=N (migrate id > N) +$from = $startId > 0 ? ", id > $startId" : ""; echo $apply - ? "MODE: APPLY — writing changes to movies (batch $batchSize)\n\n" - : "MODE: DRY-RUN — no writes; re-run with --apply to write (batch $batchSize)\n\n"; + ? "MODE: APPLY — writing changes to movies (batch $batchSize$from)\n\n" + : "MODE: DRY-RUN — no writes; re-run with --apply to write (batch $batchSize$from)\n\n"; $db = new Database_DbConnection(); $migrated = 0; $skipped = 0; $conversions = []; -events_migrate_table($db, HV_DB_TABLE_MOVIES, $apply, $batchSize, $migrated, $skipped, $conversions); +events_migrate_table($db, HV_DB_TABLE_MOVIES, $apply, $batchSize, $startId, $migrated, $skipped, $conversions); events_migration_print_unique($conversions, 'movies'); diff --git a/management/events/migrate_screenshots_events_shape.php b/management/events/migrate_screenshots_events_shape.php index f1f33f28..6f7c3412 100644 --- a/management/events/migrate_screenshots_events_shape.php +++ b/management/events/migrate_screenshots_events_shape.php @@ -13,6 +13,8 @@ * - Keyset-paginated: reads in batches of --batch rows (default 10000) and * writes each batch with ONE `CASE` UPDATE, so a table of N rows migrates in * ~2*ceil(N/batch) queries instead of one UPDATE per row. + * - --start-id=N migrates only rows with id > N — resume a huge run, or skip + * an already-done id range. * - Runs inside the maintenance window, after the new code is deployed. * * Retired in the post-deploy cleanup (with EventsStateManager). @@ -21,6 +23,7 @@ * php management/events/migrate_screenshots_events_shape.php # dry-run * php management/events/migrate_screenshots_events_shape.php --apply # write * php management/events/migrate_screenshots_events_shape.php --apply --batch=5000 + * php management/events/migrate_screenshots_events_shape.php --apply --start-id=1000000 */ require_once sprintf('%s/../../vendor/autoload.php', __DIR__); @@ -30,16 +33,18 @@ $apply = in_array('--apply', $argv, true); $batchSize = events_migration_batch_size($argv); // --batch=N (default 10000) +$startId = events_migration_start_id($argv); // --start-id=N (migrate id > N) +$from = $startId > 0 ? ", id > $startId" : ""; echo $apply - ? "MODE: APPLY — writing changes to screenshots (batch $batchSize)\n\n" - : "MODE: DRY-RUN — no writes; re-run with --apply to write (batch $batchSize)\n\n"; + ? "MODE: APPLY — writing changes to screenshots (batch $batchSize$from)\n\n" + : "MODE: DRY-RUN — no writes; re-run with --apply to write (batch $batchSize$from)\n\n"; $db = new Database_DbConnection(); $migrated = 0; $skipped = 0; $conversions = []; -events_migrate_table($db, HV_DB_TABLE_SCREENSHOTS, $apply, $batchSize, $migrated, $skipped, $conversions); +events_migrate_table($db, HV_DB_TABLE_SCREENSHOTS, $apply, $batchSize, $startId, $migrated, $skipped, $conversions); events_migration_print_unique($conversions, 'screenshots');