From 89709c7b6c5b6feccb7d683b487d193047013c44 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 22 Aug 2025 16:43:14 +0000 Subject: [PATCH 01/49] add a convenience CORS backdoor for dev environments --- src/Config.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Config.php b/src/Config.php index 16e46f457..6fbe26b26 100644 --- a/src/Config.php +++ b/src/Config.php @@ -35,7 +35,15 @@ public function __construct($file) { if ( in_array('acao_url', array_keys($this->config)) ) { - if ( in_array('HTTP_ORIGIN', array_keys($_SERVER)) + // In dev environments, allow CORS from all origins + if ( in_array('app_env', array_keys($this->config)) + && str_starts_with($this->config['app_env'], 'dev') ) { + + header("Access-Control-Allow-Origin: *"); + header("Access-Control-Allow-Methods: ".$this->config['acam']); + header("Access-Control-Allow-Headers: Content-Type"); + + } elseif ( in_array('HTTP_ORIGIN', array_keys($_SERVER)) && in_array($_SERVER['HTTP_ORIGIN'], $this->config['acao_url']) ) { header("Access-Control-Allow-Origin: ".$_SERVER['HTTP_ORIGIN']); From 034f289232dcee1a0e27c5873329568dfd04839c Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 22 Aug 2025 17:41:42 +0000 Subject: [PATCH 02/49] implement eventsapi client and the exceptions, use them for CCMC data in API --- src/Event/EventsApi.php | 91 ++++++++++++++++++++++++++++++++ src/Event/EventsApiException.php | 8 +++ src/Module/SolarEvents.php | 19 +++++++ 3 files changed, 118 insertions(+) create mode 100644 src/Event/EventsApi.php create mode 100644 src/Event/EventsApiException.php diff --git a/src/Event/EventsApi.php b/src/Event/EventsApi.php new file mode 100644 index 000000000..c7a116056 --- /dev/null +++ b/src/Event/EventsApi.php @@ -0,0 +1,91 @@ +client = $client ?? new Client(); + } + + /** + * Get events for a specific source + * + * @param DateTimeInterface $observationTime The observation time + * @param string $source The data source (e.g. "CCMC") + * @return array Array of event data + * @throws EventsApiException on API errors or unexpected responses + */ + public function getEventsForSource(DateTimeInterface $observationTime, string $source): array { + // Build the API URL: /api/v1/events/{source}/observation/{datetime} + $formattedTime = $observationTime->format('Y-m-d H:i:s'); + $encodedTime = urlencode($formattedTime); + + $url = $this->baseUrl . "/api/v1/events/{$source}/observation/{$encodedTime}"; + + Sentry::setContext('EventsApi', [ + 'url' => $url, + 'source' => $source, + 'observation_time' => $observationTime->format('Y-m-d\TH:i:s\Z') + ]); + + $response = $this->client->request('GET', $url, [ + 'timeout' => 30, + 'headers' => [ + 'Accept' => 'application/json', + 'User-Agent' => 'Helioviewer-API/2.0' + ] + ]); + + return $this->parseResponse($response); + } + + /** + * Parse the HTTP response and decode JSON + * + * @param \Psr\Http\Message\ResponseInterface $response + * @return array + * @throws EventsApiException if JSON decoding fails or response format is unexpected + */ + private function parseResponse($response): array + { + $body = (string)$response->getBody(); + $data = json_decode($body, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + Sentry::setContext('EventsApi', [ + 'raw_response' => $body, + 'json_error' => json_last_error_msg(), + 'response_status' => $response->getStatusCode() + ]); + + throw new EventsApiException("Failed to decode JSON response: " . json_last_error_msg()); + } + + if (!is_array($data)) { + Sentry::setContext('EventsApi', [ + 'unexpected_response_type' => gettype($data), + 'raw_response' => $body, + 'response_status' => $response->getStatusCode() + ]); + + throw new EventsApiException("Unexpected response format: expected array, got " . gettype($data)); + } + + return $data; + } +} diff --git a/src/Event/EventsApiException.php b/src/Event/EventsApiException.php new file mode 100644 index 000000000..db5796780 --- /dev/null +++ b/src/Event/EventsApiException.php @@ -0,0 +1,8 @@ +_params['startTime']); + // The query start time is 12 hours earlier. $start = $observationTime->sub(new DateInterval("PT12H")); @@ -224,6 +227,22 @@ public function events() { // at the center. $length = new DateInterval('P1D'); + // Handle CCMC source using new Events API + // This provides direct access to CCMC event data without going through the standard EventInterface + if (array_key_exists('sources', $this->_options) && $this->_options['sources'] === 'CCMC') { + + try { + $eventsApi = new EventsApi(); + $data = $eventsApi->getEventsForSource($observationTime, "CCMC"); + + header("Content-Type: application/json"); + echo json_encode($data); + return; + } catch (EventsApiException $e) { + Sentry::capture($e); + } + } + // Check if any specific datasources were requested if (array_key_exists('sources', $this->_options)) { $sources = explode(',', $this->_options['sources']); From 8804f161e120c36e171bc03fb5d802d10a48e04c Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Thu, 9 Oct 2025 16:05:53 +0000 Subject: [PATCH 03/49] use default eventsapi url --- src/Event/EventsApi.php | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Event/EventsApi.php b/src/Event/EventsApi.php index c7a116056..b9d899017 100644 --- a/src/Event/EventsApi.php +++ b/src/Event/EventsApi.php @@ -8,8 +8,7 @@ use Helioviewer\Api\Sentry\Sentry; class EventsApi { - - private string $baseUrl = "http://ec2-44-219-199-246.compute-1.amazonaws.com:8082"; + private ClientInterface $client; /** @@ -19,7 +18,13 @@ class EventsApi { */ public function __construct(ClientInterface $client = null) { - $this->client = $client ?? new Client(); + $this->client = $client ?? new Client([ + 'timeout' => 4, + 'headers' => [ + 'Accept' => 'application/json', + 'User-Agent' => 'Helioviewer-API/2.0' + ] + ]); } /** @@ -30,12 +35,14 @@ public function __construct(ClientInterface $client = null) * @return array Array of event data * @throws EventsApiException on API errors or unexpected responses */ - public function getEventsForSource(DateTimeInterface $observationTime, string $source): array { + public function getEventsForSource(DateTimeInterface $observationTime, string $source): array + { // Build the API URL: /api/v1/events/{source}/observation/{datetime} $formattedTime = $observationTime->format('Y-m-d H:i:s'); $encodedTime = urlencode($formattedTime); - - $url = $this->baseUrl . "/api/v1/events/{$source}/observation/{$encodedTime}"; + + $baseUrl = defined('HV_EVENTS_API_URL') ? HV_EVENTS_API_URL : 'https://events.helioviewer.org'; + $url = $baseUrl . "/api/v1/events/{$source}/observation/{$encodedTime}"; Sentry::setContext('EventsApi', [ 'url' => $url, @@ -43,13 +50,7 @@ public function getEventsForSource(DateTimeInterface $observationTime, string $s 'observation_time' => $observationTime->format('Y-m-d\TH:i:s\Z') ]); - $response = $this->client->request('GET', $url, [ - 'timeout' => 30, - 'headers' => [ - 'Accept' => 'application/json', - 'User-Agent' => 'Helioviewer-API/2.0' - ] - ]); + $response = $this->client->request('GET', $url); return $this->parseResponse($response); } From e984b39ea5c70dd94c3a555f383b6af0bf38c5f4 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 10 Oct 2025 14:48:25 +0000 Subject: [PATCH 04/49] add tests for eventsapi integration --- tests/unit_tests/events/EventsApiTest.php | 86 +++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/unit_tests/events/EventsApiTest.php diff --git a/tests/unit_tests/events/EventsApiTest.php b/tests/unit_tests/events/EventsApiTest.php new file mode 100644 index 000000000..a7b47a40d --- /dev/null +++ b/tests/unit_tests/events/EventsApiTest.php @@ -0,0 +1,86 @@ + + */ + +use PHPUnit\Framework\TestCase; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Psr7\Response; +use Helioviewer\Api\Event\EventsApi; +use Helioviewer\Api\Event\EventsApiException; + +final class EventsApiTest extends TestCase +{ + private $mockClient; + private $eventsApi; + + protected function setUp(): void + { + $this->mockClient = $this->createMock(ClientInterface::class); + $this->eventsApi = new EventsApi($this->mockClient); + } + + public function testItShouldGetEventsSuccessfully(): void + { + $responseData = [ + ['id' => 1, 'type' => 'event'.rand()], + ['id' => 2, 'type' => 'event'.rand()] + ]; + + $this->mockClient->expects($this->once()) + ->method('request') + ->with('GET', $this->stringContains('/api/v1/events/CCMC/observation/')) + ->willReturn(new Response(200, [], json_encode($responseData))); + + $result = $this->eventsApi->getEventsForSource( + new DateTimeImmutable('2024-01-15 12:00:00'), + 'CCMC' + ); + + $this->assertEquals($responseData, $result); + } + + public function testItShouldUrlEncodeObservationTime(): void + { + $this->mockClient->expects($this->once()) + ->method('request') + ->with('GET', $this->stringContains('2024-01-15+12%3A30%3A45')) + ->willReturn(new Response(200, [], json_encode([]))); + + $this->eventsApi->getEventsForSource( + new DateTimeImmutable('2024-01-15 12:30:45'), + 'CCMC' + ); + } + + public function testItShouldThrowExceptionOnInvalidJson(): void + { + $this->mockClient->expects($this->once()) + ->method('request') + ->willReturn(new Response(200, [], 'invalid json {')); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Failed to decode JSON response'); + + $this->eventsApi->getEventsForSource( + new DateTimeImmutable('2024-01-15 12:00:00'), + 'CCMC' + ); + } + + public function testItShouldThrowExceptionWhenResponseIsNotArray(): void + { + $this->mockClient->expects($this->once()) + ->method('request') + ->willReturn(new Response(200, [], '"just a string"')); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Unexpected response format: expected array, got string'); + + $this->eventsApi->getEventsForSource( + new DateTimeImmutable('2024-01-15 12:00:00'), + 'CCMC' + ); + } +} From 5f83263489e09c0e1319e113dbf3dfb573be90f8 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Wed, 22 Oct 2025 16:58:05 +0000 Subject: [PATCH 05/49] default URL for eventsapi in API config --- settings/Config.Example.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/settings/Config.Example.ini b/settings/Config.Example.ini index 6d943c986..9cb35f875 100644 --- a/settings/Config.Example.ini +++ b/settings/Config.Example.ini @@ -85,6 +85,9 @@ db_events = true ; Leave blank for no password, otherwise choose a long and sufficiently random string 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" + [movie_params] ; FFmpeg location ffmpeg = ffmpeg From 393782dcec68f2a0a2689b41ae58fd6dce4848d9 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Thu, 30 Oct 2025 18:07:02 +0000 Subject: [PATCH 06/49] use events api for movie generation and screenshots --- .../Composite/HelioviewerCompositeImage.php | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index 1b5160d8b..4b4f700df 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -19,6 +19,10 @@ require_once HV_ROOT_DIR.'/../src/Database/ImgIndex.php'; require_once HV_ROOT_DIR.'/../src/Module/SolarBodies.php'; +use Helioviewer\Api\Sentry\Sentry; +use Helioviewer\Api\Event\EventsApi; +use Helioviewer\Api\Event\EventsApiException; + class Image_Composite_HelioviewerCompositeImage { private $_composite; @@ -587,16 +591,32 @@ private function _addEventLayer($imagickImage) { require_once HV_ROOT_DIR . "/../src/Helper/EventInterface.php"; // Collect events from all data sources. + // Collect all HEK events $hek = new Event_HEKAdapter(); $event_categories = $hek->getNormalizedEvents($this->date, Array()); + $events_api_sources = ["CCMC", "RHESSI"]; + $observationTime = new DateTimeImmutable($this->date); $startDate = $observationTime->sub(new DateInterval("PT12H")); $length = new DateInterval("P1D"); - $event_categories = array_merge($event_categories, Helper_EventInterface::GetEvents($startDate, $length, $observationTime)); - // Lay down all relevant event REGIONS first + // Collect CCMC events if any + try { + + $eventsApi = new EventsApi(); + $event_categories = array_merge($event_categories, $eventsApi->getEventsForSource($observationTime, "CCMC")); + // if there is no error only left is RHESSI to collect + $events_api_sources = ["RHESSI"]; + } catch (EventsApiException $e) { + Sentry::capture($e); + } + + // Collect RHESSI events + $event_categories = array_merge($event_categories, Helper_EventInterface::GetEvents($startDate, $length, $observationTime, $events_api_sources)); + + // Lay down all relevant event REGIONS first $events_to_render = []; $events_manager = $this->eventsManager; $add_label_visibility_and_concept = function($events_data, $event_cat_pin, $event_group_name) use ($events_manager) { From f9f0413487a4bb349ed89f3ddb00997fa00475ec Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Sat, 21 Feb 2026 19:53:49 +0000 Subject: [PATCH 07/49] set a default timeout configuration for timeout(s) of eventsapi requests. --- settings/Config.Example.ini | 3 +++ src/Config.php | 6 ++++-- src/Event/EventsApi.php | 3 ++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/settings/Config.Example.ini b/settings/Config.Example.ini index 9cb35f875..d9f907838 100644 --- a/settings/Config.Example.ini +++ b/settings/Config.Example.ini @@ -88,6 +88,9 @@ 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 + [movie_params] ; FFmpeg location ffmpeg = ffmpeg diff --git a/src/Config.php b/src/Config.php index 6fbe26b26..8b7c2207c 100644 --- a/src/Config.php +++ b/src/Config.php @@ -19,7 +19,7 @@ class Config { private $_bools = array('disable_cache', 'enable_statistics_collection', 'db_events','sentry_enabled'); private $_ints = array('build_num', 'ffmpeg_max_threads', 'max_jpx_frames', 'max_movie_frames'); - private $_floats = array(); + private $_floats = array('events_api_timeout'); private $config; /** @@ -91,7 +91,9 @@ private function _fixTypes() { // floats foreach ($this->_floats as $float) { - $this->config[$float] = (float)$this->config[$float]; + if (isset($this->config[$float])) { + $this->config[$float] = (float)$this->config[$float]; + } } } diff --git a/src/Event/EventsApi.php b/src/Event/EventsApi.php index b9d899017..eef5998be 100644 --- a/src/Event/EventsApi.php +++ b/src/Event/EventsApi.php @@ -18,8 +18,9 @@ class EventsApi { */ public function __construct(ClientInterface $client = null) { + $timeout = defined('HV_EVENTS_API_TIMEOUT') ? HV_EVENTS_API_TIMEOUT : 10; $this->client = $client ?? new Client([ - 'timeout' => 4, + 'timeout' => $timeout, 'headers' => [ 'Accept' => 'application/json', 'User-Agent' => 'Helioviewer-API/2.0' From 60e4197839b1c04d191e10b5bcd88b33f5ee4fa4 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Sat, 21 Feb 2026 21:25:42 +0000 Subject: [PATCH 08/49] reduce code dublication, also remove db and event_interface reading of events --- src/Module/AbstractModule.php | 70 +++++++++++++++++++++++++++++++++ src/Module/JHelioviewer.php | 6 +-- src/Module/Movies.php | 54 ++----------------------- src/Module/SolarBodies.php | 33 ++-------------- src/Module/SolarEvents.php | 63 +++++++++-------------------- src/Module/WebClient.php | 63 ++--------------------------- src/Module/interface.Module.php | 3 ++ 7 files changed, 103 insertions(+), 189 deletions(-) create mode 100644 src/Module/AbstractModule.php diff --git a/src/Module/AbstractModule.php b/src/Module/AbstractModule.php new file mode 100644 index 000000000..7261884c1 --- /dev/null +++ b/src/Module/AbstractModule.php @@ -0,0 +1,70 @@ +_printJSON(json_encode([ + 'status_code' => $code, + 'status_txt' => $message, + 'data' => $data, + ])); + } + + /** + * Helper function to output result as either JSON or JSONP + * + * @param string $json JSON object string + * @param bool $xml Whether to wrap an XML response as JSONP + * @param bool $utf Whether to return result as UTF-8 + * + * @return void + */ + protected function _printJSON($json, $xml=false, $utf=false) + { + // Wrap JSONP requests with callback + if (isset($this->_params['callback'])) { + // For XML responses, surround with quotes and remove newlines to + // make a valid JavaScript string + if ($xml) { + $xmlStr = str_replace("\n", '', str_replace("'", "\'", $json)); + $json = sprintf("%s('%s')", $this->_params['callback'], $xmlStr); + } + else { + $json = sprintf("%s(%s)", $this->_params['callback'], $json); + } + } + + // Set Content-type HTTP header + if ($utf) { + header('Content-type: application/json;charset=UTF-8'); + } + else { + header('Content-Type: application/json'); + } + + // Print result + echo $json; + } +} diff --git a/src/Module/JHelioviewer.php b/src/Module/JHelioviewer.php index d65638df6..daad63ba9 100644 --- a/src/Module/JHelioviewer.php +++ b/src/Module/JHelioviewer.php @@ -13,11 +13,11 @@ * @license http://www.mozilla.org/MPL/MPL-1.1.html Mozilla Public License 1.1 * @link https://github.com/Helioviewer-Project */ -require_once 'interface.Module.php'; - +use Helioviewer\Api\Module\AbstractModule; +use Helioviewer\Api\Module\Module as ModuleInterface; use Helioviewer\Api\Sentry\Sentry; -class Module_JHelioviewer implements Module { +class Module_JHelioviewer extends AbstractModule implements ModuleInterface { private $_params; private $_options; diff --git a/src/Module/Movies.php b/src/Module/Movies.php index 6e81d2079..a79ce2484 100644 --- a/src/Module/Movies.php +++ b/src/Module/Movies.php @@ -12,13 +12,12 @@ * @license http://www.mozilla.org/MPL/MPL-1.1.html Mozilla Public License 1.1 * @link https://github.com/Helioviewer-Project */ -require_once 'interface.Module.php'; - +use Helioviewer\Api\Module\AbstractModule; +use Helioviewer\Api\Module\Module as ModuleInterface; use Helioviewer\Api\Event\EventsStateManager; - use Helioviewer\Api\Sentry\Sentry; -class Module_Movies implements Module { +class Module_Movies extends AbstractModule implements ModuleInterface { const YOUTUBE_THUMBNAIL_FORMAT = "https://i.ytimg.com/vi/{VideoID}/{Quality}default.jpg"; private $_params; @@ -1462,33 +1461,6 @@ public function playMovie() { * * @return void */ - private function _printJSON($json, $xml=false, $utf=false) - { - // Wrap JSONP requests with callback - if(isset($this->_params['callback'])) { - // For XML responses, surround with quotes and remove newlines to - // make a valid JavaScript string - if ($xml) { - $xmlStr = str_replace("\n", '', str_replace("'", "\'", $json)); - $json = sprintf("%s('%s')", $this->_params['callback'], $xmlStr); - } - else { - $json = sprintf("%s(%s)", $this->_params['callback'], $json); - } - } - - // Set Content-type HTTP header - if ($utf) { - header('Content-type: application/json;charset=UTF-8'); - } - else { - header('Content-Type: application/json'); - } - - // Print result - echo $json; - } - /** * Generates a youtube movie thumbnail link from the Movie's youtube Id * @@ -1631,25 +1603,5 @@ public function validate() { return true; } - - /** - * Helper function to handle response code and response message with - * output result as either JSON or JSONP - * - * @param int $code HTTP response code to return - * @param string $message Message for the response code, - * @param mixed $data Data can be anything - * - * @return void - */ - private function _sendResponse(int $code, string $message, mixed $data) : void - { - http_response_code($code); - $this->_printJSON(json_encode([ - 'status_code' => $code, - 'status_txt' => $message, - 'data' => $data, - ])); - } } ?> diff --git a/src/Module/SolarBodies.php b/src/Module/SolarBodies.php index 59f868026..55dc52071 100644 --- a/src/Module/SolarBodies.php +++ b/src/Module/SolarBodies.php @@ -1,7 +1,7 @@ _params['callback'])) { - // For XML responses, surround with quotes and remove newlines to - // make a valid JavaScript string - if ($xml) { - $xmlStr = str_replace("\n", '', str_replace("'", "\'", $json)); - $json = sprintf("%s('%s')", $this->_params['callback'], $xmlStr); - } - else { - $json = sprintf("%s(%s)", $this->_params['callback'], $json); - } - } - - // Set Content-type HTTP header - if ($utf) { - header('Content-type: application/json;charset=UTF-8'); - } - else { - header('Content-Type: application/json'); - } - - // Print result - echo $json; - } - public function getValidationRules(): array { switch( $this->_params['action'] ) { case 'getSolarBodies': diff --git a/src/Module/SolarEvents.php b/src/Module/SolarEvents.php index 42d6d0852..347912d07 100644 --- a/src/Module/SolarEvents.php +++ b/src/Module/SolarEvents.php @@ -12,14 +12,13 @@ * @license http://www.mozilla.org/MPL/MPL-1.1.html Mozilla Public License 1.1 * @link https://github.com/Helioviewer-Project */ -require_once 'interface.Module.php'; -require_once HV_ROOT_DIR . "/../src/Helper/EventInterface.php"; - +use Helioviewer\Api\Module\AbstractModule; +use Helioviewer\Api\Module\Module as ModuleInterface; use Helioviewer\Api\Sentry\Sentry; use Helioviewer\Api\Event\EventsApi; use Helioviewer\Api\Event\EventsApiException; -class Module_SolarEvents implements Module { +class Module_SolarEvents extends AbstractModule implements ModuleInterface { private $_params; private $_options; @@ -216,59 +215,33 @@ private function getHekEvents() { } public function events() { - // The given time is the observation time. $observationTime = new DateTimeImmutable($this->_params['startTime']); - // The query start time is 12 hours earlier. - $start = $observationTime->sub(new DateInterval("PT12H")); + // All event sources supported by the Events API + $allSources = ['CCMC', 'HEK', 'RHESSI']; - // The query duration will be 24 hours. - // This results in a query of events over 24 hours with the given time - // at the center. - $length = new DateInterval('P1D'); + // Determine which sources to query + if (array_key_exists('sources', $this->_options)) { + $sources = explode(',', $this->_options['sources']); + } else { + $sources = $allSources; + } - // Handle CCMC source using new Events API - // This provides direct access to CCMC event data without going through the standard EventInterface - if (array_key_exists('sources', $this->_options) && $this->_options['sources'] === 'CCMC') { + // Fetch events from each source via EventsApi + $eventsApi = new EventsApi(); + $data = []; + foreach ($sources as $source) { try { - $eventsApi = new EventsApi(); - $data = $eventsApi->getEventsForSource($observationTime, "CCMC"); - - header("Content-Type: application/json"); - echo json_encode($data); - return; + $sourceData = $eventsApi->getEventsForSource($observationTime, $source); + $data = array_merge($data, $sourceData); } catch (EventsApiException $e) { Sentry::capture($e); + return $this->_sendResponse(500, 'Internal Server Error', 'Failed to fetch events from ' . $source); } } - // Check if any specific datasources were requested - if (array_key_exists('sources', $this->_options)) { - $sources = explode(',', $this->_options['sources']); - // Special case for HEK since it doesn't go through the event interface - $hekData = []; - if (in_array("HEK", $sources)) { - // Remove HEK from the array - $sources = array_filter($sources, function ($source) {return $source != "HEK";}); - // Get the HEK data - $hekData = $this->getHekEvents(); - } - - // Query the rest of the data - $data = Helper_EventInterface::GetEvents($start, $length, $observationTime, $sources); - - // Merge with the HEK data - $data = array_merge($hekData, $data); - } else { - $hekData = $this->getHekEvents(); - // Simple case where there's no sources specified, just return everything - $data = Helper_EventInterface::GetEvents($start, $length, $observationTime); - $data = array_merge($hekData, $data); - } - header("Content-Type: application/json"); - echo json_encode($data); } diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index 7bc198b23..cf41a7017 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -13,14 +13,15 @@ * @license http://www.mozilla.org/MPL/MPL-1.1.html Mozilla Public License 1.1 * @link https://github.com/Helioviewer-Project */ -require_once "interface.Module.php"; require_once HV_ROOT_DIR.'/../src/Validation/InputValidator.php'; require_once HV_ROOT_DIR.'/../src/Helper/ErrorHandler.php'; +use Helioviewer\Api\Module\AbstractModule; +use Helioviewer\Api\Module\Module as ModuleInterface; use Helioviewer\Api\Event\EventsStateManager; use Helioviewer\Api\Sentry\Sentry; -class Module_WebClient implements Module { +class Module_WebClient extends AbstractModule implements ModuleInterface { private $_params; private $_options; @@ -1555,64 +1556,6 @@ private function _getTileCacheFilename($directory, $filename, $scale, $x, $y, $d ); } - /** - * Helper function to handle response code and response message with - * output result as either JSON or JSONP - * - * @param int $code HTTP response code to return - * @param string $message Message for the response code, - * @param mixed $data Data can be anything - * - * @return void - */ - private function _sendResponse(int $code, string $message, mixed $data) : void - { - http_response_code($code); - $this->_printJSON(json_encode([ - 'status_code' => $code, - 'status_txt' => $message, - 'data' => $data, - ])); - } - - /** - * Helper function to output result as either JSON or JSONP - * - * @param string $json JSON object string - * @param bool $xml Whether to wrap an XML response as JSONP - * @param bool $utf Whether to return result as UTF-8 - * - * @return void - */ - private function _printJSON($json, $xml=false, $utf=false) { - - // Wrap JSONP requests with callback - if ( isset($this->_params['callback']) ) { - - // For XML responses, surround with quotes and remove newlines to - // make a valid JavaScript string - if ($xml) { - $xmlStr = str_replace("\n", '', str_replace("'", "\'", $json)); - $json = sprintf("%s('%s')", $this->_params['callback'], - $xmlStr); - } - else { - $json = sprintf("%s(%s)", $this->_params['callback'], $json); - } - } - - // Set Content-type HTTP header - if ($utf) { - header('Content-type: application/json;charset=UTF-8'); - } - else { - header('Content-Type: application/json'); - } - - // Print result - echo $json; - } - /** * Converts from tile coordinates to physical coordinates in arcseconds * and uses those coordinates to return an ROI object diff --git a/src/Module/interface.Module.php b/src/Module/interface.Module.php index eb03c3eff..7b088fc94 100644 --- a/src/Module/interface.Module.php +++ b/src/Module/interface.Module.php @@ -11,6 +11,9 @@ * @license http://www.mozilla.org/MPL/MPL-1.1.html Mozilla Public License 1.1 * @link https://github.com/Helioviewer-Project */ + +namespace Helioviewer\Api\Module; + interface Module { /** * Executes the requested action From d5997183d28dc763ec5a2e05f71182acf15c15d8 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Sat, 21 Feb 2026 22:21:20 +0000 Subject: [PATCH 09/49] use psr4 naming for module interface --- src/Module/JHelioviewer.php | 2 +- src/Module/{interface.Module.php => ModuleInterface.php} | 2 +- src/Module/Movies.php | 2 +- src/Module/SolarBodies.php | 2 +- src/Module/SolarEvents.php | 2 +- src/Module/WebClient.php | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) rename src/Module/{interface.Module.php => ModuleInterface.php} (96%) diff --git a/src/Module/JHelioviewer.php b/src/Module/JHelioviewer.php index daad63ba9..9d1f81da0 100644 --- a/src/Module/JHelioviewer.php +++ b/src/Module/JHelioviewer.php @@ -14,7 +14,7 @@ * @link https://github.com/Helioviewer-Project */ use Helioviewer\Api\Module\AbstractModule; -use Helioviewer\Api\Module\Module as ModuleInterface; +use Helioviewer\Api\Module\ModuleInterface; use Helioviewer\Api\Sentry\Sentry; class Module_JHelioviewer extends AbstractModule implements ModuleInterface { diff --git a/src/Module/interface.Module.php b/src/Module/ModuleInterface.php similarity index 96% rename from src/Module/interface.Module.php rename to src/Module/ModuleInterface.php index 7b088fc94..71b9dfc66 100644 --- a/src/Module/interface.Module.php +++ b/src/Module/ModuleInterface.php @@ -14,7 +14,7 @@ namespace Helioviewer\Api\Module; -interface Module { +interface ModuleInterface { /** * Executes the requested action * diff --git a/src/Module/Movies.php b/src/Module/Movies.php index a79ce2484..82c1ff394 100644 --- a/src/Module/Movies.php +++ b/src/Module/Movies.php @@ -13,7 +13,7 @@ * @link https://github.com/Helioviewer-Project */ use Helioviewer\Api\Module\AbstractModule; -use Helioviewer\Api\Module\Module as ModuleInterface; +use Helioviewer\Api\Module\ModuleInterface; use Helioviewer\Api\Event\EventsStateManager; use Helioviewer\Api\Sentry\Sentry; diff --git a/src/Module/SolarBodies.php b/src/Module/SolarBodies.php index 55dc52071..0cf58399d 100644 --- a/src/Module/SolarBodies.php +++ b/src/Module/SolarBodies.php @@ -1,7 +1,7 @@ Date: Mon, 23 Feb 2026 21:03:53 +0000 Subject: [PATCH 10/49] move events to legacyevents --- src/Event/EventsApi.php | 2 +- src/Image/Composite/HelioviewerCompositeImage.php | 2 +- src/Module/SolarEvents.php | 12 +----------- tests/unit_tests/events/EventsApiTest.php | 8 ++++---- 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/Event/EventsApi.php b/src/Event/EventsApi.php index eef5998be..4a5c322db 100644 --- a/src/Event/EventsApi.php +++ b/src/Event/EventsApi.php @@ -36,7 +36,7 @@ public function __construct(ClientInterface $client = null) * @return array Array of event data * @throws EventsApiException on API errors or unexpected responses */ - public function getEventsForSource(DateTimeInterface $observationTime, string $source): array + public function getEventsForSourceLegacy(DateTimeInterface $observationTime, string $source): array { // Build the API URL: /api/v1/events/{source}/observation/{datetime} $formattedTime = $observationTime->format('Y-m-d H:i:s'); diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index 4b4f700df..c754cda95 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -605,7 +605,7 @@ private function _addEventLayer($imagickImage) { try { $eventsApi = new EventsApi(); - $event_categories = array_merge($event_categories, $eventsApi->getEventsForSource($observationTime, "CCMC")); + $event_categories = array_merge($event_categories, $eventsApi->getEventsForSourceLegacy($observationTime, "CCMC")); // if there is no error only left is RHESSI to collect $events_api_sources = ["RHESSI"]; diff --git a/src/Module/SolarEvents.php b/src/Module/SolarEvents.php index bce155900..11f490471 100644 --- a/src/Module/SolarEvents.php +++ b/src/Module/SolarEvents.php @@ -204,16 +204,6 @@ public function importEvents() { } } - /** - * Retrieves HEK events in a normalized format - */ - private function getHekEvents() { - include_once HV_ROOT_DIR.'/../src/Event/HEKAdapter.php'; - $hek = new Event_HEKAdapter(); - $data = $hek->getNormalizedEvents($this->_params['startTime'], $this->_options); - return $data; - } - public function events() { $observationTime = new DateTimeImmutable($this->_params['startTime']); @@ -233,7 +223,7 @@ public function events() { foreach ($sources as $source) { try { - $sourceData = $eventsApi->getEventsForSource($observationTime, $source); + $sourceData = $eventsApi->getEventsForSourceLegacy($observationTime, $source); $data = array_merge($data, $sourceData); } catch (EventsApiException $e) { Sentry::capture($e); diff --git a/tests/unit_tests/events/EventsApiTest.php b/tests/unit_tests/events/EventsApiTest.php index a7b47a40d..a0851019b 100644 --- a/tests/unit_tests/events/EventsApiTest.php +++ b/tests/unit_tests/events/EventsApiTest.php @@ -33,7 +33,7 @@ public function testItShouldGetEventsSuccessfully(): void ->with('GET', $this->stringContains('/api/v1/events/CCMC/observation/')) ->willReturn(new Response(200, [], json_encode($responseData))); - $result = $this->eventsApi->getEventsForSource( + $result = $this->eventsApi->getEventsForSourceLegacy( new DateTimeImmutable('2024-01-15 12:00:00'), 'CCMC' ); @@ -48,7 +48,7 @@ public function testItShouldUrlEncodeObservationTime(): void ->with('GET', $this->stringContains('2024-01-15+12%3A30%3A45')) ->willReturn(new Response(200, [], json_encode([]))); - $this->eventsApi->getEventsForSource( + $this->eventsApi->getEventsForSourceLegacy( new DateTimeImmutable('2024-01-15 12:30:45'), 'CCMC' ); @@ -63,7 +63,7 @@ public function testItShouldThrowExceptionOnInvalidJson(): void $this->expectException(EventsApiException::class); $this->expectExceptionMessage('Failed to decode JSON response'); - $this->eventsApi->getEventsForSource( + $this->eventsApi->getEventsForSourceLegacy( new DateTimeImmutable('2024-01-15 12:00:00'), 'CCMC' ); @@ -78,7 +78,7 @@ public function testItShouldThrowExceptionWhenResponseIsNotArray(): void $this->expectException(EventsApiException::class); $this->expectExceptionMessage('Unexpected response format: expected array, got string'); - $this->eventsApi->getEventsForSource( + $this->eventsApi->getEventsForSourceLegacy( new DateTimeImmutable('2024-01-15 12:00:00'), 'CCMC' ); From 47816115604c31a39f33d64d511fe2ce101d24e4 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Wed, 25 Feb 2026 17:50:26 +0000 Subject: [PATCH 11/49] do a cleaning to simplify getDataCoverageEndpoints, --- src/Module/WebClient.php | 272 +++++++++++++++++++++++++-------------- 1 file changed, 178 insertions(+), 94 deletions(-) diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index 0fe99cc51..be12e69e4 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -947,126 +947,169 @@ public function getSciDataScript() /** * Retrieves the latest usage statistics from the database */ + /** + * API Endpoint: getDataCoverage + * + * Returns data coverage information for either IMAGE layers or EVENT layers. + * Used by the timeline/chart component in the Helioviewer web client. + * + * Request Parameters (all in milliseconds): + * - imageLayers: String of image layer definitions (for image coverage) + * - eventLayers: String of event layer definitions (for event coverage) + * - startDate: Start timestamp in milliseconds + * - endDate: End timestamp in milliseconds + * - currentDate: Current observation timestamp (for highlighting active events) + * + * Response: + * JSON array of data series for chart visualization + * + * Note: Either imageLayers OR eventLayers must be provided, not both. + */ public function getDataCoverage() { + // Route to appropriate handler based on which layer type is provided + if (!empty($this->_options['imageLayers'])) { + return $this->getDataCoverageForLayers(); + } else if (!empty($this->_options['eventLayers'])) { + return $this->getDataCoverageForEvents(); + } else { + return $this->_sendResponse(400, 'eventLayers or imageLayers needs to be set for this endpoint to work in API', ''); + } + } + + /** + * Returns data coverage for IMAGE layers. + * Queries the data_coverage_30_min table for image availability data. + */ + public function getDataCoverageForLayers() { include_once HV_ROOT_DIR.'/../src/Helper/HelioviewerLayers.php'; + include_once HV_ROOT_DIR.'/../src/Database/Statistics.php'; + + // Parse image layers (e.g., "[SDO,AIA,171,1,100]") + $layers = new Helper_HelioviewerLayers($this->_options['imageLayers']); + + // Parse and validate time parameters + $timeParams = $this->_parseTimeParameters(); + if ($timeParams === null) { + return $this->_sendResponse(400, 'Invalid time parameters', 'startDate, endDate, and currentDate must be numeric timestamps in milliseconds'); + } + + // Determine resolution based on time range + $resolution = $this->_calculateResolution($timeParams['range']); + + // Fetch and return coverage data + $statistics = new Database_Statistics(); + $this->_printJSON( + $statistics->getDataCoverage( + $layers, + $resolution, + $timeParams['dateStart'], + $timeParams['dateEnd'] + ) + ); + } + + /** + * Returns data coverage for EVENT layers. + * Queries the events/events_coverage tables for event availability data. + * TODO: Migrate to use EventsApi instead of local database tables + */ + public function getDataCoverageForEvents() { include_once HV_ROOT_DIR.'/../src/Helper/HelioviewerEvents.php'; + include_once HV_ROOT_DIR.'/../src/Database/Statistics.php'; - // Data Layers - if(!empty($this->_options['imageLayers'])){ - $layers = new Helper_HelioviewerLayers($this->_options['imageLayers']); - }else{ - $layers = null; + // Parse event layers (e.g., "[AR,all,1],[FL,all,1]") + $events = new Helper_HelioviewerEvents($this->_options['eventLayers']); + + // Parse and validate time parameters + $timeParams = $this->_parseTimeParameters(); + if ($timeParams === null) { + return $this->_sendResponse(400, 'Invalid time parameters', 'startDate, endDate, and currentDate must be numeric timestamps in milliseconds'); + } + + // Determine resolution based on time range + $resolution = $this->_calculateResolution($timeParams['range']); + + // For events, force minute resolution for ranges < 24 hours + if ($timeParams['range'] < 24 * 60 * 60 * 1000) { + $resolution = 'm'; } - // Events Layers - if(!empty($this->_options['eventLayers'])){ - $events = new Helper_HelioviewerEvents($this->_options['eventLayers']); - }else{ - $events = null; + // Events don't support 5m/15m resolution - upgrade to 30m + // (events_coverage table only has 30m, 1H, 1D, 1W, 1M, 1Y buckets) + if ($resolution == '5m' || $resolution == '15m') { + $resolution = '30m'; } + // Fetch and return coverage data + $statistics = new Database_Statistics(); + $this->_printJSON( + $statistics->getDataCoverageEvents( + $events, + $resolution, + $timeParams['dateStart'], + $timeParams['dateEnd'], + $timeParams['dateCurrent'] + ) + ); + } + + /** + * Parses and validates time parameters from request options. + * All timestamps are expected in MILLISECONDS (JavaScript convention). + * + * @return array|null Returns null if validation fails, otherwise returns: + * array{ + * start: int, + * end: int, + * current: int, + * range: int, + * dateStart: DateTime, + * dateEnd: DateTime, + * dateCurrent: DateTime + * } + */ + private function _parseTimeParameters(): ?array { + // Validate numeric format $start = @$this->_options['startDate']; if ($start && !preg_match('/^[0-9]+$/', $start)) { - die("Invalid start parameter: $start"); + return null; } $end = @$this->_options['endDate']; if ($end && !preg_match('/^[0-9]+$/', $end)) { - die("Invalid end parameter: $end"); + return null; } $current = @$this->_options['currentDate']; if ($current && !preg_match('/^[0-9]+$/', $current)) { - die("Invalid end parameter: $current"); + return null; } + + // Defaults: start=0, end=now, current=0 if (!$start) $start = 0; if (!$end) $end = time() * 1000; if (!$current) $current = 0; - // set some utility variables + // Calculate range $range = $end - $start; - // find the right range - if ($range < 105 * 60 * 1000) { - $resolution = 'm'; - - // 12 hours range loads hourly data - } elseif ($range < 12 * 3600 * 1000) { - $resolution = '5m'; - - // one month range loads hourly data - } elseif ($range < 2 * 24 * 3600 * 1000) { - $resolution = '15m'; - - // one month range loads hourly data - } elseif ($range < 10 * 24 * 3600 * 1000) { - $resolution = 'h'; - - // one year range loads daily data - } elseif ($range < 6 * 31 * 24 * 3600 * 1000) { - $resolution = 'D'; - - // half year range loads daily data - } elseif ($range < 15 * 31 * 24 * 3600 * 1000) { - $resolution = 'W'; - - // greater range loads monthly data - } else { - $resolution = 'M'; - } - //$resolution = 'm'; - + // Convert to DateTime objects (from milliseconds to seconds) $dateEnd = new DateTime(); - if ( isset($this->_options['endDate']) ) { - $dateEnd->setTimestamp(intval($this->_options['endDate']/1000)); - }else{ - $dateEnd->setTimestamp(intval($end/1000)); - } - $dateStart = new DateTime(); - if ( isset($this->_options['startDate']) ) { - $dateStart->setTimestamp(intval($this->_options['startDate']/1000)); - }else{ - $dateStart->setTimestamp(intval($start/1000)); - } - $dateCurrent = new DateTime(); - if ( isset($this->_options['currentDate']) ) { - $dateCurrent->setTimestamp( $this->_options['currentDate']); - }else{ - $dateCurrent->setTimestamp( $current); - } - - include_once HV_ROOT_DIR.'/../src/Database/Statistics.php'; - $statistics = new Database_Statistics(); - - if($layers != null){ - $this->_printJSON( - $statistics->getDataCoverage( - $layers, - $resolution, - $dateStart, - $dateEnd - ) - ); - }else if($events != null){ - if ($range < 24 * 60 * 60 * 1000) { - $resolution = 'm'; - } - - if($resolution == '5m' || $resolution == '15m' ){ - $resolution = '30m'; - } - $this->_printJSON( - $statistics->getDataCoverageEvents( - $events, - $resolution, - $dateStart, - $dateEnd, - $dateCurrent - ) - ); - } else { - return $this->_sendResponse(400, 'eventLayers or imageLayers needs to be set for this endpoint to work in API', ''); - } + $dateEnd->setTimestamp(intval($end / 1000)); + $dateStart = new DateTime(); + $dateStart->setTimestamp(intval($start / 1000)); + $dateCurrent = new DateTime(); + $dateCurrent->setTimestamp(intval($current / 1000)); + + return [ + 'start' => $start, + 'end' => $end, + 'current' => $current, + 'range' => $range, + 'dateStart' => $dateStart, + 'dateEnd' => $dateEnd, + 'dateCurrent' => $dateCurrent + ]; } /** @@ -1594,6 +1637,47 @@ private function _tileCoordinatesToROI ($x, $y, $scale, $jp2Scale, * * @return date The date given, or the helioviewer minimum date. */ + /** + * Determines the appropriate data resolution based on the time range. + * + * This auto-scales the data granularity for performance and usability: + * - Smaller time ranges get finer resolution (individual data points) + * - Larger time ranges get coarser resolution (aggregated buckets) + * + * @param int $rangeMs Time range in milliseconds (end - start) + * @return string Resolution code: 'm', '5m', '15m', 'h', 'D', 'W', or 'M' + */ + private function _calculateResolution(int $rangeMs): string { + if ($rangeMs < 105 * 60 * 1000) { + // < 1.75 hours: Show individual events/data points (minute-level) + return 'm'; + + } elseif ($rangeMs < 12 * 3600 * 1000) { + // < 12 hours: 5-minute buckets + return '5m'; + + } elseif ($rangeMs < 2 * 24 * 3600 * 1000) { + // < 2 days: 15-minute buckets + return '15m'; + + } elseif ($rangeMs < 10 * 24 * 3600 * 1000) { + // < 10 days: Hourly buckets + return 'h'; + + } elseif ($rangeMs < 6 * 31 * 24 * 3600 * 1000) { + // < 6 months: Daily buckets + return 'D'; + + } elseif ($rangeMs < 15 * 31 * 24 * 3600 * 1000) { + // < 15 months: Weekly buckets + return 'W'; + + } else { + // >= 15 months: Monthly buckets + return 'M'; + } + } + private function _clampDate($date) { $minDate = new DateTime(HV_MINIMUM_DATE); if ($date < $minDate) { From 886f0ec8ba2f0ee9f0492535f5794d8eb8974152 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Wed, 25 Feb 2026 18:43:30 +0000 Subject: [PATCH 12/49] simplification and more comments for eventscoverage --- src/Database/Statistics.php | 209 +++++++++++++++++++++++++++--------- 1 file changed, 159 insertions(+), 50 deletions(-) diff --git a/src/Database/Statistics.php b/src/Database/Statistics.php index fea3f7fc1..8a0c8f684 100644 --- a/src/Database/Statistics.php +++ b/src/Database/Statistics.php @@ -1321,43 +1321,78 @@ public function getDataCoverage($layers, $resolution, $startDate, $endDate) { /** * Gets latest datasource coverage and return as JSON + * + * This function returns event coverage data for timeline/chart display. + * It supports multiple time resolutions and returns data formatted for visualization. + * + * @param EventLayers $events - Collection of event layers to query (event_type + frm_name pairs) + * @param string $resolution - Time bucket size: 'm' (minute), '5m', '15m', '30m', 'h', 'D', 'W', 'M', 'Y' + * @param DateTime $startDate - Start of the visible time range + * @param DateTime $endDate - End of the visible time range + * @param DateTime $currentDate - Current observation time (used for highlighting active events) + * + * @return string JSON encoded array of event series for chart display */ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate, $currentDate) { + require_once HV_ROOT_DIR.'/../src/Helper/DateTimeConversions.php'; $selectedEvents = $events; - // Proceed to query for HEK event coverage + // ======================================================================= + // STEP 1: EXPAND TIME RANGE + // ======================================================================= + // Calculate the distance between start and end, then expand the query range + // by that same distance on both sides. This provides buffer data for smooth + // scrolling/panning in the UI timeline. + // Example: If user views Jan 1-10, we query Dec 22 - Jan 20 $distance = $endDate->getTimestamp() - $startDate->getTimestamp(); - $interval = new DateInterval('PT'.$distance.'S'); + // Store the ORIGINAL visible range (before expansion) for later filtering $visibleStartTimestamp = $startDate->getTimestamp(); $visibleEndTimestamp = $endDate->getTimestamp(); + // Expand the range: startDate goes back, endDate goes forward $startDate->modify('-'.$distance.' seconds'); $endDate->modify('+'.$distance.' seconds'); + // Convert expanded dates to MySQL format for SQL queries $dateStart = toMySQLDateString($startDate); $dateEnd = toMySQLDateString($endDate); - $startTimestamp = $startDate->getTimestamp(); - $endTimestamp = $endDate->getTimestamp(); - $currentTimestamp = $currentDate->getTimestamp(); + // Store timestamps for comparisons + $startTimestamp = $startDate->getTimestamp(); // Expanded start + $endTimestamp = $endDate->getTimestamp(); // Expanded end + $currentTimestamp = $currentDate->getTimestamp(); // Current observation time - $sources = array(); + // ======================================================================= + // STEP 2: INITIALIZE OUTPUT STRUCTURE + // ======================================================================= + $sources = array(); // Final output: array of event series if(!$events){ return json_encode(array()); } + // EVENT_KEYS maps event_type codes to numeric keys for chart series + // e.g., 'AR' => 1, 'CE' => 2, 'FL' => 3, etc. $eventsKeys = self::EVENT_KEYS; + // EVENT_COLORS maps event_type codes to hex colors for chart display $eventsColors = self::EVENT_COLORS; - $dbData = array(); - $dbVisibleData = array(); - $layersString = ''; + // ======================================================================= + // STEP 3: BUILD SQL WHERE CLAUSE FROM EVENT LAYERS + // ======================================================================= + // Parse the requested event layers and build SQL filter conditions. + // Each layer has an event_type (e.g., 'AR', 'FL') and optional frm_name + // (Feature Recognition Method, e.g., 'NOAA_SWPC_Observer') + $dbData = array(); // Temporary storage for aggregated counts + $dbVisibleData = array(); // Tracks which series have visible data + $layersString = ''; // SQL WHERE clause fragment + foreach($events->toArray() as $layer){ + // If specific FRM(s) requested, filter by both event_type AND frm_name if(!empty($layer['frm_name']) && $layer['frm_name'] != 'all'){ $frms = explode(';', $layer['frm_name']); foreach($frms as $frm_name){ @@ -1365,31 +1400,45 @@ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate $layersString .= ' OR '; } $frm_name = str_replace('_', ' ', $frm_name); + // Builds: (event_type = "AR" AND frm_name = "NOAA SWPC Observer") $layersString .= '(event_type = "'.$layer['event_type'].'" AND frm_name = "'.$frm_name.'")'; } }else{ + // No specific FRM - get all events of this type if(!empty($layersString)){ $layersString .= ' OR '; } + // Builds: event_type = "AR" $layersString .= 'event_type = "'.$layer['event_type'].'"'; } + // Initialize the output structure for this event type if (isset($layer['event_type']) && isset($eventsKeys[$layer['event_type']])) { $eventKey = $eventsKeys[ $layer['event_type'] ]; $dbData[$eventKey] = array(); - $dbVisibleData[$eventKey] = false; + $dbVisibleData[$eventKey] = false; // Will be set true if data falls in visible range $sources[$eventKey] = array( - 'data' => array(), - 'event_type' => $layer['event_type'], - 'res' => $resolution, - 'showInLegend' => false + 'data' => array(), // Event data points + 'event_type' => $layer['event_type'], // e.g., 'AR', 'FL' + 'res' => $resolution, // Resolution used + 'showInLegend' => false // Only show in legend if has visible data ); } } - + // ======================================================================= + // STEP 4: BUILD SQL QUERY BASED ON RESOLUTION + // ======================================================================= + // For fine resolutions (m, 5m, 15m): Query raw 'events' table + // For coarse resolutions (30m+): Query pre-aggregated 'events_coverage' table switch ($resolution) { + // ----------------------------------------------------------------- + // FINE RESOLUTIONS: Query raw 'events' table for individual events + // These show actual event bars on the timeline + // ----------------------------------------------------------------- case 'm': + // Minute resolution: Show individual event bars + // Query: Get all events that overlap with the expanded time range $sql = 'SELECT * FROM events @@ -1403,6 +1452,8 @@ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate break; case '5m': + // 5-minute resolution: Still shows individual events + // Timestamps are aligned to 5-minute boundaries $sql = 'SELECT * FROM events @@ -1411,15 +1462,17 @@ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate $beginInterval = new DateTime(); $endInterval = new DateTime(); + // Align to 5-minute boundaries (floor to nearest 300 seconds) $beginInterval->setTimestamp(floor($startTimestamp / 300) * 300); $endInterval->setTimestamp(floor($endTimestamp / 300) * 300); + // Create time period for generating empty data buckets $interval = DateInterval::createFromDateString('5 minutes'); $period = new DatePeriod($beginInterval, $interval, $endInterval); - $periodSeconds = 300000; break; case '15m': + // 15-minute resolution: Still shows individual events $sql = 'SELECT * FROM events @@ -1428,15 +1481,23 @@ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate $beginInterval = new DateTime(); $endInterval = new DateTime(); + // Align to 15-minute boundaries (floor to nearest 900 seconds) $beginInterval->setTimestamp(floor($startTimestamp / 900) * 900); $endInterval->setTimestamp(floor($endTimestamp / 900) * 900); $interval = DateInterval::createFromDateString('15 minutes'); $period = new DatePeriod($beginInterval, $interval, $endInterval); - $periodSeconds = 900000; break; + + // ----------------------------------------------------------------- + // COARSE RESOLUTIONS: Query pre-aggregated 'events_coverage' table + // These show event COUNTS per time bucket (bar chart style) + // The 'events_coverage' table is populated by batch jobs + // ----------------------------------------------------------------- case '30m': + // 30-minute resolution: Query aggregated counts from events_coverage + // Returns: date, event_type, count (number of events in that 30-min bucket) $sql = 'SELECT date, event_type, @@ -1448,12 +1509,12 @@ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate $beginInterval = new DateTime(); $endInterval = new DateTime(); + // Align to 30-minute boundaries (floor to nearest 1800 seconds) $beginInterval->setTimestamp(floor($startTimestamp / 1800) * 1800); $endInterval->setTimestamp(floor($endTimestamp / 1800) * 1800); $interval = DateInterval::createFromDateString('30 minutes'); $period = new DatePeriod($beginInterval, $interval, $endInterval); - $periodSeconds = 1800000; break; case 'h': @@ -1471,7 +1532,6 @@ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate $interval = DateInterval::createFromDateString('1 hour'); $period = new DatePeriod($beginInterval, $interval, $endInterval); - $periodSeconds = 3600000; break; case 'D': @@ -1547,30 +1607,41 @@ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate throw new Exception($msg, 25); } - //build 0 data array + // ======================================================================= + // STEP 5: BUILD EMPTY DATA BUCKETS (for non-minute resolutions) + // ======================================================================= + // For aggregated views (5m, 15m, 30m, etc.), create an array of time buckets + // initialized to 0. This ensures we have data points even for empty periods. if($resolution != 'm'){ $emptyData = array(); foreach ( $period as $dt ){ + // Key is timestamp in milliseconds, value is 0 (no events yet) $emptyData[ ($dt->getTimestamp() * 1000) ] = 0; } } - //Query SQL Data + // ======================================================================= + // STEP 6: EXECUTE QUERY AND PROCESS RESULTS + // ======================================================================= $result = $this->_dbConnection->query($sql); - $i = 1; - $uniqueIds = array(); - $j = 0; + $j = 0; // Event index counter while ($row = $result->fetch_array(MYSQLI_ASSOC)) { - //Event Name + // Get the event type (e.g., 'AR', 'FL', 'CE') $key = $row['event_type']; - + // Map to numeric key for chart series $eventKey = $eventsKeys[$key]; - //Build data array + // --------------------------------------------------------------- + // MINUTE RESOLUTION: Build individual event bars + // --------------------------------------------------------------- if($resolution == 'm'){ + // Convert event times to milliseconds for JavaScript charts $timeStart = (strtotime($row['event_starttime'])* 1000); $timeEnd = (strtotime($row['event_endtime'])* 1000); + + // Clamp event times to the query range + // (events may extend beyond the visible window) if(($startTimestamp * 1000) > $timeStart){ $timeStart = ($beginInterval->getTimestamp() * 1000); } @@ -1578,8 +1649,11 @@ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate $timeEnd = ($endInterval->getTimestamp() * 1000); } + // Handle instantaneous events (start == end) + // Give them a minimum visual width so they're visible $modifier = 0; if($timeStart == $timeEnd){ + // Calculate a small offset based on visible range $modifier = round(($endTimestamp - $startTimestamp) / (3*60)) * 100; $startTimeToDisplay = $timeStart - $modifier; $timeEndToDisplay = $timeEnd + $modifier; @@ -1588,11 +1662,14 @@ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate $timeEndToDisplay = $timeEnd; } + // Build the event data point for the chart + // x/x2 = start/end time (for horizontal bar) + // y = vertical position (row number) $sources[$eventKey]['data'][$j] = array( - 'x' => $startTimeToDisplay, - 'x2' => $timeEndToDisplay, - 'y' => $j, - 'kb_archivid' => $row['kb_archivid'], + 'x' => $startTimeToDisplay, // Bar start position + 'x2' => $timeEndToDisplay, // Bar end position + 'y' => $j, // Row (will be recalculated later) + 'kb_archivid' => $row['kb_archivid'], // Unique event ID from HEK 'hv_labels_formatted' => json_decode($row['hv_labels_formatted']), 'event_type' => $row['event_type'], 'frm_name' => $row['frm_name'], @@ -1604,64 +1681,84 @@ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate 'modifier' => $modifier ); + // Mark instantaneous events if($timeStart == $timeEnd){ $sources[$eventKey]['data'][$j]['zeroSeconds'] = true; } + // Highlight events that are "active" at the current observation time + // Active events get white border, inactive events get dimmed color if($currentTimestamp >= $timeStart && $currentTimestamp <= $timeEnd){ $sources[$eventKey]['data'][$j]['borderColor'] = '#ffffff'; }else{ $sources[$eventKey]['data'][$j]['color'] = $this->colourBrightness($eventsColors[ $row['event_type'] ], -0.9); } + // Track if this event falls within the ORIGINAL visible range + // (not the expanded range) for legend display if($visibleEndTimestamp >= strtotime($row['event_starttime']) && $visibleStartTimestamp <= strtotime($row['event_endtime'])){ $dbVisibleData[$eventKey] = true; } - $uniqueIds[$row['frm_specificid']] = $j; $j++; - }else{ + } + // --------------------------------------------------------------- + // AGGREGATED RESOLUTIONS: Build count data points + // --------------------------------------------------------------- + else{ + // For aggregated views, we just store count per time bucket $timestamp = (strtotime($row['date'])* 1000); $dbData[$eventKey][$timestamp] = (int)$row['count']; + // Track if this bucket falls in visible range if($visibleEndTimestamp >= strtotime($row['date']) && $visibleStartTimestamp <= strtotime($row['date'])){ $dbVisibleData[$eventKey] = true; } } - $i++; } - //Fill 0 values rows + // ======================================================================= + // STEP 7: POST-PROCESS THE DATA + // ======================================================================= if($resolution != 'm'){ + // --------------------------------------------------------------- + // AGGREGATED: Merge actual counts with empty buckets + // --------------------------------------------------------------- + // This ensures we have a data point for every time bucket, + // even if no events occurred (count = 0) foreach($dbData as $key=>$row){ foreach($emptyData as $timestamp=>$count){ + // If we have actual data for this bucket, use it if(isset($dbData[$key]) && isset($dbData[$key][ $timestamp ])){ $count = $dbData[$key][ $timestamp ]; } + // Add [timestamp, count] pair to the series $sources[$key]['data'][] = array($timestamp, (int)$count); } } }else{ + // --------------------------------------------------------------- + // MINUTE: Stack overlapping events into rows (swim lanes) + // --------------------------------------------------------------- + // Events that overlap in time need to be placed on different rows + // This is a "greedy" algorithm that places each event on the first + // row where it doesn't overlap with existing events ksort($sources); $i = 1; - $levels = array(); + $levels = array(); // Tracks events in each row foreach($sources as $k=>$series){ - //loop over all the events - //$i = count($levels); - //$levels = array(); $data = array(); foreach($series['data'] as $dk => $event){ - //was this event placed in a level already? $placed = false; - //loop through each level checking only the last event + + // Try to place event in an existing row foreach($levels as $row=>$events){ - //we only need to check the last event if they are already sorted + // Check only the last event in this row (events are sorted) $last = end($events); - //does the current event start after the end time of the last event in this level + // If current event starts after last event ends, it fits here if($event['x'] >= $last['x2']){ - //add to this level and break out of the inner loop $event['y'] = $row; $levels[$row][] = $event; $data[] = $event; @@ -1669,7 +1766,8 @@ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate break; } } - //if not placed in another level, add a new level + + // If no existing row works, create a new row if(!$placed){ $levels[$i] = array($event); $event['y'] = $i; @@ -1682,18 +1780,25 @@ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate } - //Remove not visible events + // ======================================================================= + // STEP 8: SET LEGEND VISIBILITY + // ======================================================================= + // Only show event types in legend if they have data in the visible range foreach($dbVisibleData as $k => $isVisible){ if($isVisible){ $sources[$k]['showInLegend'] = true; } } - // Handle non HEK data + // ======================================================================= + // STEP 9: HANDLE NON-HEK EVENT SOURCES (placeholder/stub) + // ======================================================================= + // This loop was intended to add coverage data for non-HEK event sources + // (like CCMC, RHESSI, etc.) via GetDataCoverageForEvent(). + // However, GetDataCoverageForEvent() currently returns empty arrays + // for all event types, so this effectively does nothing. + // TODO: If migrating to EventsApi, this could be replaced with API calls foreach($selectedEvents->toArray() as $layer) { - // Any NON-HEK events will have their coverage handler executed in GetDataCoverageForEvent. - // For HEK event types, this will return an empty array, so we can just drop them. - // For NON-HEK event types, this will return actual data which should be placed into the final data array. $data = self::GetDataCoverageForEvent($layer, $resolution, $startDate, $endDate, $currentDate); if (count($data) > 0) { $eventKey = $eventsKeys[ $layer['event_type'] ]; @@ -1702,6 +1807,10 @@ public function getDataCoverageEvents($events, $resolution, $startDate, $endDate } } + // ======================================================================= + // STEP 10: FINALIZE AND RETURN + // ======================================================================= + // Sort by event key and convert to indexed array for JSON output ksort($sources); $sources = array_values($sources); return json_encode($sources); From 6c5fd652e5f9d616aa04278dd7404a80bf80ba99 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Wed, 18 Mar 2026 21:52:20 +0000 Subject: [PATCH 13/49] updated eventsapi tests --- src/Event/Api/EventsApi.php | 155 ++++++++++++++++++++ src/Event/{ => Api}/EventsApiException.php | 2 +- src/Event/Api/EventsApiInterface.php | 41 ++++++ src/Event/EventsApi.php | 93 ------------ tests/unit_tests/events/EventsApiTest.php | 157 +++++++++++++++++++-- 5 files changed, 340 insertions(+), 108 deletions(-) create mode 100644 src/Event/Api/EventsApi.php rename src/Event/{ => Api}/EventsApiException.php (61%) create mode 100644 src/Event/Api/EventsApiInterface.php delete mode 100644 src/Event/EventsApi.php diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php new file mode 100644 index 000000000..7cea1242b --- /dev/null +++ b/src/Event/Api/EventsApi.php @@ -0,0 +1,155 @@ +client = $client ?? new Client([ + 'base_uri' => $baseUrl, + 'timeout' => $timeout, + 'connect_timeout' => $connectTimeout, + 'headers' => [ + 'Accept' => 'application/json', + 'User-Agent' => 'Helioviewer-API/2.0' + ] + ]); + $this->sentry = $sentry ?? Sentry::$client; + + $this->sentry->setContext('EventsApi', [ + 'api_url' => $baseUrl, + 'timeout' => $timeout, + 'connect_timeout' => $connectTimeout, + ]); + } + + /** {@inheritdoc} */ + public function getEventsForSourceLegacy(DateTimeInterface $observationTime, string $source): array + { + // Build the API URL: /api/v1/events/{source}/observation/{datetime} + $formattedTime = $observationTime->format('Y-m-d H:i:s'); + $encodedTime = urlencode($formattedTime); + + $url = "/helioviewer/events/{$source}/observation/{$encodedTime}"; + + $this->sentry->setContext('EventsApi', [ + 'endpoint' => $url, + ]); + + try { + $response = $this->client->request('GET', $url); + return $this->parseResponse($response); + } catch (\Exception $e) { + $this->sentry->setContext('EventsApi', [ + 'error' => $e->getMessage(), + ]); + $exception = new EventsApiException("Failed to fetch events for source: " . $e->getMessage(), 0, $e); + $this->sentry->capture($exception); + throw $exception; + } + } + + /** {@inheritdoc} */ + public function getEventsInRange(int $fromTimestamp, int $toTimestamp, array $paths): array + { + $url = "/helioviewer/events/from/{$fromTimestamp}/to/{$toTimestamp}"; + + $this->sentry->setContext('EventsApi', [ + 'endpoint' => $url, + 'paths' => $paths + ]); + + try { + $response = $this->client->request('POST', $url, [ + 'json' => ['paths' => $paths] + ]); + + return $this->parseResponse($response); + } catch (\Exception $e) { + $this->sentry->setContext('EventsApi', [ + 'error' => $e->getMessage(), + ]); + $exception = new EventsApiException("Failed to fetch events: " . $e->getMessage(), 0, $e); + $this->sentry->capture($exception); + throw $exception; + } + } + + /** {@inheritdoc} */ + public function getDistributions(string $size, int $fromTimestamp, int $toTimestamp, array $paths): array + { + $url = "/helioviewer/distributions/size/{$size}/from/{$fromTimestamp}/to/{$toTimestamp}"; + + $this->sentry->setContext('EventsApi', [ + 'endpoint' => $url, + 'paths' => $paths + ]); + + try { + $response = $this->client->request('POST', $url, [ + 'json' => ['paths' => $paths] + ]); + + return $this->parseResponse($response); + } catch (\Exception $e) { + $this->sentry->setContext('EventsApi', [ + 'error' => $e->getMessage(), + ]); + $exception = new EventsApiException("Failed to fetch distributions: " . $e->getMessage(), 0, $e); + $this->sentry->capture($exception); + throw $exception; + } + } + + /** + * Parse the HTTP response and decode JSON + * + * @param \Psr\Http\Message\ResponseInterface $response + * @return array + * @throws EventsApiException if JSON decoding fails or response format is unexpected + */ + private function parseResponse($response): array + { + $body = (string)$response->getBody(); + $data = json_decode($body, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + $this->sentry->setContext('EventsApi', [ + 'raw_response' => $body, + 'json_error' => json_last_error_msg(), + 'response_status' => $response->getStatusCode() + ]); + throw new \RuntimeException("Failed to decode JSON response: " . json_last_error_msg()); + } + + if (!is_array($data)) { + $this->sentry->setContext('EventsApi', [ + 'unexpected_response_type' => gettype($data), + 'raw_response' => $body, + 'response_status' => $response->getStatusCode() + ]); + throw new \RuntimeException("Unexpected response format: expected array, got " . gettype($data)); + } + + return $data; + } +} diff --git a/src/Event/EventsApiException.php b/src/Event/Api/EventsApiException.php similarity index 61% rename from src/Event/EventsApiException.php rename to src/Event/Api/EventsApiException.php index db5796780..84ea9cc02 100644 --- a/src/Event/EventsApiException.php +++ b/src/Event/Api/EventsApiException.php @@ -1,6 +1,6 @@ >DONKI>>CME", "HEK>>Active Region"]) + * @return array Array of event data + * @throws EventsApiException on API errors or unexpected responses + */ + public function getEventsInRange(int $fromTimestamp, int $toTimestamp, array $paths): array; + + /** + * Get event distributions (counts per time bucket) for given selection paths + * + * @param string $size Bucket size: 30m, h, D, W, M, Y + * @param int $fromTimestamp Unix timestamp (seconds) for range start + * @param int $toTimestamp Unix timestamp (seconds) for range end + * @param array $paths Array of selection paths (e.g. ["CCMC>>DONKI>>CME", "HEK>>Flare"]) + * @return array Distribution data with buckets containing counts per event type + * @throws EventsApiException on API errors or unexpected responses + */ + public function getDistributions(string $size, int $fromTimestamp, int $toTimestamp, array $paths): array; +} diff --git a/src/Event/EventsApi.php b/src/Event/EventsApi.php deleted file mode 100644 index 4a5c322db..000000000 --- a/src/Event/EventsApi.php +++ /dev/null @@ -1,93 +0,0 @@ -client = $client ?? new Client([ - 'timeout' => $timeout, - 'headers' => [ - 'Accept' => 'application/json', - 'User-Agent' => 'Helioviewer-API/2.0' - ] - ]); - } - - /** - * Get events for a specific source - * - * @param DateTimeInterface $observationTime The observation time - * @param string $source The data source (e.g. "CCMC") - * @return array Array of event data - * @throws EventsApiException on API errors or unexpected responses - */ - public function getEventsForSourceLegacy(DateTimeInterface $observationTime, string $source): array - { - // Build the API URL: /api/v1/events/{source}/observation/{datetime} - $formattedTime = $observationTime->format('Y-m-d H:i:s'); - $encodedTime = urlencode($formattedTime); - - $baseUrl = defined('HV_EVENTS_API_URL') ? HV_EVENTS_API_URL : 'https://events.helioviewer.org'; - $url = $baseUrl . "/api/v1/events/{$source}/observation/{$encodedTime}"; - - Sentry::setContext('EventsApi', [ - 'url' => $url, - 'source' => $source, - 'observation_time' => $observationTime->format('Y-m-d\TH:i:s\Z') - ]); - - $response = $this->client->request('GET', $url); - - return $this->parseResponse($response); - } - - /** - * Parse the HTTP response and decode JSON - * - * @param \Psr\Http\Message\ResponseInterface $response - * @return array - * @throws EventsApiException if JSON decoding fails or response format is unexpected - */ - private function parseResponse($response): array - { - $body = (string)$response->getBody(); - $data = json_decode($body, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - Sentry::setContext('EventsApi', [ - 'raw_response' => $body, - 'json_error' => json_last_error_msg(), - 'response_status' => $response->getStatusCode() - ]); - - throw new EventsApiException("Failed to decode JSON response: " . json_last_error_msg()); - } - - if (!is_array($data)) { - Sentry::setContext('EventsApi', [ - 'unexpected_response_type' => gettype($data), - 'raw_response' => $body, - 'response_status' => $response->getStatusCode() - ]); - - throw new EventsApiException("Unexpected response format: expected array, got " . gettype($data)); - } - - return $data; - } -} diff --git a/tests/unit_tests/events/EventsApiTest.php b/tests/unit_tests/events/EventsApiTest.php index a0851019b..9fa3c7941 100644 --- a/tests/unit_tests/events/EventsApiTest.php +++ b/tests/unit_tests/events/EventsApiTest.php @@ -7,30 +7,46 @@ use PHPUnit\Framework\TestCase; use GuzzleHttp\ClientInterface; use GuzzleHttp\Psr7\Response; -use Helioviewer\Api\Event\EventsApi; -use Helioviewer\Api\Event\EventsApiException; +use Helioviewer\Api\Event\Api\EventsApi; +use Helioviewer\Api\Event\Api\EventsApiException; +use Helioviewer\Api\Sentry\ClientInterface as SentryClientInterface; final class EventsApiTest extends TestCase { private $mockClient; + private $mockSentry; private $eventsApi; protected function setUp(): void { $this->mockClient = $this->createMock(ClientInterface::class); - $this->eventsApi = new EventsApi($this->mockClient); + $this->mockSentry = $this->createMock(SentryClientInterface::class); + $this->eventsApi = new EventsApi($this->mockClient, $this->mockSentry); } - public function testItShouldGetEventsSuccessfully(): void + public function testConstructorSetsDefaultSentryContext(): void + { + $this->mockSentry->expects($this->once()) + ->method('setContext') + ->with('EventsApi', $this->callback(function ($params) { + return array_key_exists('api_url', $params) + && array_key_exists('timeout', $params) + && array_key_exists('connect_timeout', $params); + })); + + new EventsApi($this->mockClient, $this->mockSentry); + } + + public function testGetEventsForSourceLegacySuccess(): void { $responseData = [ - ['id' => 1, 'type' => 'event'.rand()], - ['id' => 2, 'type' => 'event'.rand()] + ['id' => 1, 'type' => 'CME'], + ['id' => 2, 'type' => 'Flare'] ]; $this->mockClient->expects($this->once()) ->method('request') - ->with('GET', $this->stringContains('/api/v1/events/CCMC/observation/')) + ->with('GET', $this->stringContains('/helioviewer/events/CCMC/observation/')) ->willReturn(new Response(200, [], json_encode($responseData))); $result = $this->eventsApi->getEventsForSourceLegacy( @@ -41,7 +57,30 @@ public function testItShouldGetEventsSuccessfully(): void $this->assertEquals($responseData, $result); } - public function testItShouldUrlEncodeObservationTime(): void + public function testGetEventsForSourceLegacySetsEndpointContext(): void + { + $this->mockClient->method('request') + ->willReturn(new Response(200, [], json_encode([]))); + + // First call is from constructor, second from the method + $this->mockSentry->expects($this->exactly(2)) + ->method('setContext') + ->withConsecutive( + ['EventsApi', $this->anything()], + ['EventsApi', $this->callback(function ($params) { + return array_key_exists('endpoint', $params) + && str_contains($params['endpoint'], '/helioviewer/events/CCMC/observation/'); + })] + ); + + $eventsApi = new EventsApi($this->mockClient, $this->mockSentry); + $eventsApi->getEventsForSourceLegacy( + new DateTimeImmutable('2024-01-15 12:00:00'), + 'CCMC' + ); + } + + public function testGetEventsForSourceLegacyUrlEncodesObservationTime(): void { $this->mockClient->expects($this->once()) ->method('request') @@ -54,12 +93,35 @@ public function testItShouldUrlEncodeObservationTime(): void ); } - public function testItShouldThrowExceptionOnInvalidJson(): void + public function testGetEventsForSourceLegacyThrowsAndCapturesOnError(): void { - $this->mockClient->expects($this->once()) - ->method('request') + $this->mockClient->method('request') + ->willThrowException(new \RuntimeException('connection failed')); + + // Constructor setContext + method setContext + error setContext = 3, then capture + $this->mockSentry->expects($this->atLeastOnce())->method('setContext'); + $this->mockSentry->expects($this->once()) + ->method('capture') + ->with($this->isInstanceOf(EventsApiException::class)); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Failed to fetch events for source: connection failed'); + + $this->eventsApi->getEventsForSourceLegacy( + new DateTimeImmutable('2024-01-15 12:00:00'), + 'CCMC' + ); + } + + public function testGetEventsForSourceLegacyThrowsOnInvalidJson(): void + { + $this->mockClient->method('request') ->willReturn(new Response(200, [], 'invalid json {')); + $this->mockSentry->expects($this->once()) + ->method('capture') + ->with($this->isInstanceOf(EventsApiException::class)); + $this->expectException(EventsApiException::class); $this->expectExceptionMessage('Failed to decode JSON response'); @@ -69,12 +131,15 @@ public function testItShouldThrowExceptionOnInvalidJson(): void ); } - public function testItShouldThrowExceptionWhenResponseIsNotArray(): void + public function testGetEventsForSourceLegacyThrowsWhenResponseIsNotArray(): void { - $this->mockClient->expects($this->once()) - ->method('request') + $this->mockClient->method('request') ->willReturn(new Response(200, [], '"just a string"')); + $this->mockSentry->expects($this->once()) + ->method('capture') + ->with($this->isInstanceOf(EventsApiException::class)); + $this->expectException(EventsApiException::class); $this->expectExceptionMessage('Unexpected response format: expected array, got string'); @@ -83,4 +148,68 @@ public function testItShouldThrowExceptionWhenResponseIsNotArray(): void 'CCMC' ); } + + public function testGetEventsInRangeSuccess(): void + { + $responseData = [['id' => 1, 'type' => 'CME']]; + $paths = ['CCMC>>DONKI>>CME', 'HEK>>Active Region']; + + $this->mockClient->expects($this->once()) + ->method('request') + ->with('POST', '/helioviewer/events/from/1000/to/2000', [ + 'json' => ['paths' => $paths] + ]) + ->willReturn(new Response(200, [], json_encode($responseData))); + + $result = $this->eventsApi->getEventsInRange(1000, 2000, $paths); + + $this->assertEquals($responseData, $result); + } + + public function testGetEventsInRangeThrowsAndCapturesOnError(): void + { + $this->mockClient->method('request') + ->willThrowException(new \RuntimeException('timeout')); + + $this->mockSentry->expects($this->once()) + ->method('capture') + ->with($this->isInstanceOf(EventsApiException::class)); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Failed to fetch events: timeout'); + + $this->eventsApi->getEventsInRange(1000, 2000, ['CCMC>>DONKI>>CME']); + } + + public function testGetDistributionsSuccess(): void + { + $responseData = [['bucket' => '2024-01-15', 'count' => 5]]; + $paths = ['CCMC>>DONKI>>CME']; + + $this->mockClient->expects($this->once()) + ->method('request') + ->with('POST', '/helioviewer/distributions/size/h/from/1000/to/2000', [ + 'json' => ['paths' => $paths] + ]) + ->willReturn(new Response(200, [], json_encode($responseData))); + + $result = $this->eventsApi->getDistributions('h', 1000, 2000, $paths); + + $this->assertEquals($responseData, $result); + } + + public function testGetDistributionsThrowsAndCapturesOnError(): void + { + $this->mockClient->method('request') + ->willThrowException(new \RuntimeException('server error')); + + $this->mockSentry->expects($this->once()) + ->method('capture') + ->with($this->isInstanceOf(EventsApiException::class)); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Failed to fetch distributions: server error'); + + $this->eventsApi->getDistributions('h', 1000, 2000, ['CCMC>>DONKI>>CME']); + } } From 956fce8c4bba2248782ddd809b0ecc34d37761c7 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Thu, 19 Mar 2026 21:56:28 +0000 Subject: [PATCH 14/49] remove old eventtimeline function from statistics --- src/Database/Statistics.php | 685 ------------------------------------ 1 file changed, 685 deletions(-) diff --git a/src/Database/Statistics.php b/src/Database/Statistics.php index 8a0c8f684..8bfeb3ed2 100644 --- a/src/Database/Statistics.php +++ b/src/Database/Statistics.php @@ -21,73 +21,6 @@ class Database_Statistics { private $_dbConnection; - private const EVENT_COLORS = array( - 'AR' => '#ff8f97', - 'CE' => '#ffb294', - 'CME' => '#ffb294', - 'CD' => '#ffd391', - 'CH' => '#fef38e', - 'CW' => '#ebff8c', - 'FI' => '#c8ff8d', - 'FE' => '#a3ff8d', - 'FA' => '#7bff8e', - 'FL' => '#7affae', - 'FP' => '#74b0c5', - 'LP' => '#7cffc9', - 'OS' => '#81fffc', - 'SS' => '#8ce6ff', - 'EF' => '#95c6ff', - 'CJ' => '#9da4ff', - 'PG' => '#ab8cff', - 'OT' => '#d4d4d4', - 'NR' => '#d4d4d4', - 'SG' => '#e986ff', - 'SP' => '#ff82ff', - 'CR' => '#ff85ff', - 'CC' => '#ff8acc', - 'ER' => '#ff8dad', - 'TO' => '#ca89ff', - 'HY' => '#00ffff', - 'BO' => '#a7e417', - 'EE' => '#fec00a', - 'PB' => '#b3d5e4', - 'PT' => '#494a37', - 'UNK' => '#d4d4d4' - ); - - private const EVENT_KEYS = array( - 'AR' => 0, - 'CC' => 1, - 'CD' => 2, - 'CH' => 3, - 'CJ' => 4, - 'CE' => 5, - 'CR' => 6, - 'CW' => 7, - 'EF' => 8, - 'ER' => 9, - 'FI' => 10, - 'FA' => 11, - 'FE' => 12, - 'FL' => 13, - 'LP' => 14, - 'OS' => 15, - 'PG' => 16, - 'SG' => 17, - 'SP' => 18, - 'SS' => 19, - //unused - 'OT' => 20, - 'NR' => 21, - 'TO' => 22, - 'HY' => 23, - 'BO' => 24, - 'EE' => 25, - 'PB' => 26, - 'PT' => 27, - 'UNK' => 28, - 'FP' => 29 - ); /** * Constructor @@ -1319,546 +1252,6 @@ public function getDataCoverage($layers, $resolution, $startDate, $endDate) { } - /** - * Gets latest datasource coverage and return as JSON - * - * This function returns event coverage data for timeline/chart display. - * It supports multiple time resolutions and returns data formatted for visualization. - * - * @param EventLayers $events - Collection of event layers to query (event_type + frm_name pairs) - * @param string $resolution - Time bucket size: 'm' (minute), '5m', '15m', '30m', 'h', 'D', 'W', 'M', 'Y' - * @param DateTime $startDate - Start of the visible time range - * @param DateTime $endDate - End of the visible time range - * @param DateTime $currentDate - Current observation time (used for highlighting active events) - * - * @return string JSON encoded array of event series for chart display - */ - public function getDataCoverageEvents($events, $resolution, $startDate, $endDate, $currentDate) { - - require_once HV_ROOT_DIR.'/../src/Helper/DateTimeConversions.php'; - $selectedEvents = $events; - - // ======================================================================= - // STEP 1: EXPAND TIME RANGE - // ======================================================================= - // Calculate the distance between start and end, then expand the query range - // by that same distance on both sides. This provides buffer data for smooth - // scrolling/panning in the UI timeline. - // Example: If user views Jan 1-10, we query Dec 22 - Jan 20 - $distance = $endDate->getTimestamp() - $startDate->getTimestamp(); - - // Store the ORIGINAL visible range (before expansion) for later filtering - $visibleStartTimestamp = $startDate->getTimestamp(); - $visibleEndTimestamp = $endDate->getTimestamp(); - - // Expand the range: startDate goes back, endDate goes forward - $startDate->modify('-'.$distance.' seconds'); - $endDate->modify('+'.$distance.' seconds'); - - // Convert expanded dates to MySQL format for SQL queries - $dateStart = toMySQLDateString($startDate); - $dateEnd = toMySQLDateString($endDate); - - // Store timestamps for comparisons - $startTimestamp = $startDate->getTimestamp(); // Expanded start - $endTimestamp = $endDate->getTimestamp(); // Expanded end - $currentTimestamp = $currentDate->getTimestamp(); // Current observation time - - // ======================================================================= - // STEP 2: INITIALIZE OUTPUT STRUCTURE - // ======================================================================= - $sources = array(); // Final output: array of event series - - if(!$events){ - return json_encode(array()); - } - - // EVENT_KEYS maps event_type codes to numeric keys for chart series - // e.g., 'AR' => 1, 'CE' => 2, 'FL' => 3, etc. - $eventsKeys = self::EVENT_KEYS; - - // EVENT_COLORS maps event_type codes to hex colors for chart display - $eventsColors = self::EVENT_COLORS; - - // ======================================================================= - // STEP 3: BUILD SQL WHERE CLAUSE FROM EVENT LAYERS - // ======================================================================= - // Parse the requested event layers and build SQL filter conditions. - // Each layer has an event_type (e.g., 'AR', 'FL') and optional frm_name - // (Feature Recognition Method, e.g., 'NOAA_SWPC_Observer') - $dbData = array(); // Temporary storage for aggregated counts - $dbVisibleData = array(); // Tracks which series have visible data - $layersString = ''; // SQL WHERE clause fragment - - foreach($events->toArray() as $layer){ - - // If specific FRM(s) requested, filter by both event_type AND frm_name - if(!empty($layer['frm_name']) && $layer['frm_name'] != 'all'){ - $frms = explode(';', $layer['frm_name']); - foreach($frms as $frm_name){ - if(!empty($layersString)){ - $layersString .= ' OR '; - } - $frm_name = str_replace('_', ' ', $frm_name); - // Builds: (event_type = "AR" AND frm_name = "NOAA SWPC Observer") - $layersString .= '(event_type = "'.$layer['event_type'].'" AND frm_name = "'.$frm_name.'")'; - } - }else{ - // No specific FRM - get all events of this type - if(!empty($layersString)){ - $layersString .= ' OR '; - } - // Builds: event_type = "AR" - $layersString .= 'event_type = "'.$layer['event_type'].'"'; - } - - // Initialize the output structure for this event type - if (isset($layer['event_type']) && isset($eventsKeys[$layer['event_type']])) { - $eventKey = $eventsKeys[ $layer['event_type'] ]; - $dbData[$eventKey] = array(); - $dbVisibleData[$eventKey] = false; // Will be set true if data falls in visible range - $sources[$eventKey] = array( - 'data' => array(), // Event data points - 'event_type' => $layer['event_type'], // e.g., 'AR', 'FL' - 'res' => $resolution, // Resolution used - 'showInLegend' => false // Only show in legend if has visible data - ); - } - } - - // ======================================================================= - // STEP 4: BUILD SQL QUERY BASED ON RESOLUTION - // ======================================================================= - // For fine resolutions (m, 5m, 15m): Query raw 'events' table - // For coarse resolutions (30m+): Query pre-aggregated 'events_coverage' table - switch ($resolution) { - // ----------------------------------------------------------------- - // FINE RESOLUTIONS: Query raw 'events' table for individual events - // These show actual event bars on the timeline - // ----------------------------------------------------------------- - case 'm': - // Minute resolution: Show individual event bars - // Query: Get all events that overlap with the expanded time range - $sql = 'SELECT - * - FROM events - WHERE ('.$layersString.') AND (event_endtime >= "'.$dateStart.'" AND event_starttime <= "'.$dateEnd.'") - ORDER BY event_starttime;'; - - $beginInterval = new DateTime(); - $endInterval = new DateTime(); - $beginInterval->setTimestamp($startTimestamp); - $endInterval->setTimestamp($endTimestamp); - - break; - case '5m': - // 5-minute resolution: Still shows individual events - // Timestamps are aligned to 5-minute boundaries - $sql = 'SELECT - * - FROM events - WHERE ('.$layersString.') AND (event_endtime >= "'.$dateStart.'" AND event_starttime <= "'.$dateEnd.'") - ORDER BY event_starttime;'; - - $beginInterval = new DateTime(); - $endInterval = new DateTime(); - // Align to 5-minute boundaries (floor to nearest 300 seconds) - $beginInterval->setTimestamp(floor($startTimestamp / 300) * 300); - $endInterval->setTimestamp(floor($endTimestamp / 300) * 300); - - // Create time period for generating empty data buckets - $interval = DateInterval::createFromDateString('5 minutes'); - $period = new DatePeriod($beginInterval, $interval, $endInterval); - - break; - case '15m': - // 15-minute resolution: Still shows individual events - $sql = 'SELECT - * - FROM events - WHERE ('.$layersString.') AND (event_endtime >= "'.$dateStart.'" AND event_starttime <= "'.$dateEnd.'") - ORDER BY event_starttime;'; - - $beginInterval = new DateTime(); - $endInterval = new DateTime(); - // Align to 15-minute boundaries (floor to nearest 900 seconds) - $beginInterval->setTimestamp(floor($startTimestamp / 900) * 900); - $endInterval->setTimestamp(floor($endTimestamp / 900) * 900); - - $interval = DateInterval::createFromDateString('15 minutes'); - $period = new DatePeriod($beginInterval, $interval, $endInterval); - - break; - - // ----------------------------------------------------------------- - // COARSE RESOLUTIONS: Query pre-aggregated 'events_coverage' table - // These show event COUNTS per time bucket (bar chart style) - // The 'events_coverage' table is populated by batch jobs - // ----------------------------------------------------------------- - case '30m': - // 30-minute resolution: Query aggregated counts from events_coverage - // Returns: date, event_type, count (number of events in that 30-min bucket) - $sql = 'SELECT - date, - event_type, - SUM(count) as count - FROM events_coverage - WHERE period = "30m" AND ('.$layersString.') AND `date` BETWEEN "'.$dateStart.'" AND "'.$dateEnd.'" - GROUP BY date, event_type - ORDER BY date;'; - - $beginInterval = new DateTime(); - $endInterval = new DateTime(); - // Align to 30-minute boundaries (floor to nearest 1800 seconds) - $beginInterval->setTimestamp(floor($startTimestamp / 1800) * 1800); - $endInterval->setTimestamp(floor($endTimestamp / 1800) * 1800); - - $interval = DateInterval::createFromDateString('30 minutes'); - $period = new DatePeriod($beginInterval, $interval, $endInterval); - - break; - case 'h': - $sql = 'SELECT - date, - event_type, - SUM(count) as count - FROM events_coverage - WHERE period = "1H" AND ('.$layersString.') AND `date` BETWEEN "'.$dateStart.'" AND "'.$dateEnd.'" - GROUP BY date, event_type - ORDER BY date;'; - - $beginInterval = new DateTime(date('Y-m-d H:00:00', $startTimestamp)); - $endInterval = new DateTime(date('Y-m-d H:00:00', $endTimestamp)); - - $interval = DateInterval::createFromDateString('1 hour'); - $period = new DatePeriod($beginInterval, $interval, $endInterval); - - break; - case 'D': - $sql = 'SELECT - date, - event_type, - SUM(count) as count - FROM events_coverage - WHERE period = "1D" AND ('.$layersString.') AND `date` BETWEEN "'.$dateStart.'" AND "'.$dateEnd.'" - GROUP BY date, event_type - ORDER BY date;'; - - $beginInterval = new DateTime(date('Y-m-d 00:00:00', $startTimestamp)); - $endInterval = new DateTime(date('Y-m-d 00:00:00', $endTimestamp)); - - $interval = DateInterval::createFromDateString('1 day'); - $period = new DatePeriod($beginInterval, $interval, $endInterval); - - break; - case 'W': - $sql = 'SELECT - date, - event_type, - SUM(count) as count - FROM events_coverage - WHERE period = "1W" AND ('.$layersString.') AND `date` BETWEEN "'.$dateStart.'" AND "'.$dateEnd.'" - GROUP BY date, event_type - ORDER BY date;'; - - $beginInterval = new DateTime(date('Y-m-d 00:00:00', strtotime('Last Monday', $startTimestamp))); - $endInterval = new DateTime(date('Y-m-d 00:00:00', $endTimestamp)); - - $interval = DateInterval::createFromDateString('1 week'); - $period = new DatePeriod($beginInterval, $interval, $endInterval); - - break; - case 'M': - $sql = 'SELECT - date, - event_type, - SUM(count) as count - FROM events_coverage - WHERE period = "1M" AND ('.$layersString.') AND `date` BETWEEN "'.$dateStart.'" AND "'.$dateEnd.'" - GROUP BY date, event_type - ORDER BY date;'; - - $beginInterval = new DateTime(date('Y-m-01 00:00:00', $startTimestamp)); - $endInterval = new DateTime(date('Y-m-01 00:00:00', $endTimestamp)); - - $interval = DateInterval::createFromDateString('1 month'); - $period = new DatePeriod($beginInterval, $interval, $endInterval); - - break; - case 'Y': - $sql = 'SELECT - date, - event_type, - SUM(count) as count - FROM events_coverage - WHERE period = "1Y" AND ('.$layersString.') AND `date` BETWEEN "'.$dateStart.'" AND "'.$dateEnd.'" - GROUP BY date, event_type - ORDER BY date;'; - - $beginInterval = new DateTime(date('Y-01-01 00:00:00', $startTimestamp)); - $endInterval = new DateTime(date('Y-01-01 00:00:00', $endTimestamp)); - - $interval = DateInterval::createFromDateString('1 year'); - $period = new DatePeriod($beginInterval, $interval, $endInterval); - - break; - default: - $msg = 'Invalid resolution specified. Valid options include: ' . implode(', ', $validRes); - throw new Exception($msg, 25); - } - - // ======================================================================= - // STEP 5: BUILD EMPTY DATA BUCKETS (for non-minute resolutions) - // ======================================================================= - // For aggregated views (5m, 15m, 30m, etc.), create an array of time buckets - // initialized to 0. This ensures we have data points even for empty periods. - if($resolution != 'm'){ - $emptyData = array(); - foreach ( $period as $dt ){ - // Key is timestamp in milliseconds, value is 0 (no events yet) - $emptyData[ ($dt->getTimestamp() * 1000) ] = 0; - } - } - - // ======================================================================= - // STEP 6: EXECUTE QUERY AND PROCESS RESULTS - // ======================================================================= - $result = $this->_dbConnection->query($sql); - $j = 0; // Event index counter - - while ($row = $result->fetch_array(MYSQLI_ASSOC)) { - // Get the event type (e.g., 'AR', 'FL', 'CE') - $key = $row['event_type']; - // Map to numeric key for chart series - $eventKey = $eventsKeys[$key]; - - // --------------------------------------------------------------- - // MINUTE RESOLUTION: Build individual event bars - // --------------------------------------------------------------- - if($resolution == 'm'){ - // Convert event times to milliseconds for JavaScript charts - $timeStart = (strtotime($row['event_starttime'])* 1000); - $timeEnd = (strtotime($row['event_endtime'])* 1000); - - // Clamp event times to the query range - // (events may extend beyond the visible window) - if(($startTimestamp * 1000) > $timeStart){ - $timeStart = ($beginInterval->getTimestamp() * 1000); - } - if(($endTimestamp * 1000) < $timeEnd){ - $timeEnd = ($endInterval->getTimestamp() * 1000); - } - - // Handle instantaneous events (start == end) - // Give them a minimum visual width so they're visible - $modifier = 0; - if($timeStart == $timeEnd){ - // Calculate a small offset based on visible range - $modifier = round(($endTimestamp - $startTimestamp) / (3*60)) * 100; - $startTimeToDisplay = $timeStart - $modifier; - $timeEndToDisplay = $timeEnd + $modifier; - }else{ - $startTimeToDisplay = $timeStart; - $timeEndToDisplay = $timeEnd; - } - - // Build the event data point for the chart - // x/x2 = start/end time (for horizontal bar) - // y = vertical position (row number) - $sources[$eventKey]['data'][$j] = array( - 'x' => $startTimeToDisplay, // Bar start position - 'x2' => $timeEndToDisplay, // Bar end position - 'y' => $j, // Row (will be recalculated later) - 'kb_archivid' => $row['kb_archivid'], // Unique event ID from HEK - 'hv_labels_formatted' => json_decode($row['hv_labels_formatted']), - 'event_type' => $row['event_type'], - 'frm_name' => $row['frm_name'], - 'frm_specificid' => $row['frm_specificid'], - 'event_peaktime' => $row['event_peaktime'], - 'event_starttime' => $row['event_starttime'], - 'event_endtime' => $row['event_endtime'], - 'concept' => $row['concept'], - 'modifier' => $modifier - ); - - // Mark instantaneous events - if($timeStart == $timeEnd){ - $sources[$eventKey]['data'][$j]['zeroSeconds'] = true; - } - - // Highlight events that are "active" at the current observation time - // Active events get white border, inactive events get dimmed color - if($currentTimestamp >= $timeStart && $currentTimestamp <= $timeEnd){ - $sources[$eventKey]['data'][$j]['borderColor'] = '#ffffff'; - }else{ - $sources[$eventKey]['data'][$j]['color'] = $this->colourBrightness($eventsColors[ $row['event_type'] ], -0.9); - } - - // Track if this event falls within the ORIGINAL visible range - // (not the expanded range) for legend display - if($visibleEndTimestamp >= strtotime($row['event_starttime']) && $visibleStartTimestamp <= strtotime($row['event_endtime'])){ - $dbVisibleData[$eventKey] = true; - } - - $j++; - } - // --------------------------------------------------------------- - // AGGREGATED RESOLUTIONS: Build count data points - // --------------------------------------------------------------- - else{ - // For aggregated views, we just store count per time bucket - $timestamp = (strtotime($row['date'])* 1000); - $dbData[$eventKey][$timestamp] = (int)$row['count']; - - // Track if this bucket falls in visible range - if($visibleEndTimestamp >= strtotime($row['date']) && $visibleStartTimestamp <= strtotime($row['date'])){ - $dbVisibleData[$eventKey] = true; - } - } - } - - // ======================================================================= - // STEP 7: POST-PROCESS THE DATA - // ======================================================================= - if($resolution != 'm'){ - // --------------------------------------------------------------- - // AGGREGATED: Merge actual counts with empty buckets - // --------------------------------------------------------------- - // This ensures we have a data point for every time bucket, - // even if no events occurred (count = 0) - foreach($dbData as $key=>$row){ - foreach($emptyData as $timestamp=>$count){ - // If we have actual data for this bucket, use it - if(isset($dbData[$key]) && isset($dbData[$key][ $timestamp ])){ - $count = $dbData[$key][ $timestamp ]; - } - // Add [timestamp, count] pair to the series - $sources[$key]['data'][] = array($timestamp, (int)$count); - } - } - }else{ - // --------------------------------------------------------------- - // MINUTE: Stack overlapping events into rows (swim lanes) - // --------------------------------------------------------------- - // Events that overlap in time need to be placed on different rows - // This is a "greedy" algorithm that places each event on the first - // row where it doesn't overlap with existing events - ksort($sources); - $i = 1; - - $levels = array(); // Tracks events in each row - foreach($sources as $k=>$series){ - $data = array(); - - foreach($series['data'] as $dk => $event){ - $placed = false; - - // Try to place event in an existing row - foreach($levels as $row=>$events){ - // Check only the last event in this row (events are sorted) - $last = end($events); - // If current event starts after last event ends, it fits here - if($event['x'] >= $last['x2']){ - $event['y'] = $row; - $levels[$row][] = $event; - $data[] = $event; - $placed = true; - break; - } - } - - // If no existing row works, create a new row - if(!$placed){ - $levels[$i] = array($event); - $event['y'] = $i; - $data[] = $event; - $i++; - } - } - $sources[$k]['data'] = $data; - } - - } - - // ======================================================================= - // STEP 8: SET LEGEND VISIBILITY - // ======================================================================= - // Only show event types in legend if they have data in the visible range - foreach($dbVisibleData as $k => $isVisible){ - if($isVisible){ - $sources[$k]['showInLegend'] = true; - } - } - - // ======================================================================= - // STEP 9: HANDLE NON-HEK EVENT SOURCES (placeholder/stub) - // ======================================================================= - // This loop was intended to add coverage data for non-HEK event sources - // (like CCMC, RHESSI, etc.) via GetDataCoverageForEvent(). - // However, GetDataCoverageForEvent() currently returns empty arrays - // for all event types, so this effectively does nothing. - // TODO: If migrating to EventsApi, this could be replaced with API calls - foreach($selectedEvents->toArray() as $layer) { - $data = self::GetDataCoverageForEvent($layer, $resolution, $startDate, $endDate, $currentDate); - if (count($data) > 0) { - $eventKey = $eventsKeys[ $layer['event_type'] ]; - $sources[$eventKey]['data'] = $data; - $sources[$eventKey]['showInLegend'] = true; - } - } - - // ======================================================================= - // STEP 10: FINALIZE AND RETURN - // ======================================================================= - // Sort by event key and convert to indexed array for JSON output - ksort($sources); - $sources = array_values($sources); - return json_encode($sources); - - } - /* - Change the brightness of HEX color - */ - public function colourBrightness($hex, $percent) { - // Work out if hash given - $hash = ''; - if (stristr($hex,'#')) { - $hex = str_replace('#','',$hex); - $hash = '#'; - } - /// HEX TO RGB - $rgb = array(hexdec(substr($hex,0,2)), hexdec(substr($hex,2,2)), hexdec(substr($hex,4,2))); - //// CALCULATE - for ($i=0; $i<3; $i++) { - // See if brighter or darker - if ($percent > 0) { - // Lighter - $rgb[$i] = round($rgb[$i] * $percent) + round(255 * (1-$percent)); - } else { - // Darker - $positivePercent = $percent - ($percent*2); - $rgb[$i] = round($rgb[$i] * $positivePercent) + round(0 * (1-$positivePercent)); - } - // In case rounding up causes us to go to 256 - if ($rgb[$i] > 255) { - $rgb[$i] = 255; - } - } - //// RBG to Hex - $hex = ''; - for($i=0; $i < 3; $i++) { - // Convert the decimal digit to hex - $hexDigit = dechex($rgb[$i]); - // Add a leading zero if necessary - if(strlen($hexDigit) == 1) { - $hexDigit = "0" . $hexDigit; - } - // Append to the hex string - $hex .= $hexDigit; - } - return $hash.$hex; - } - /** * Update data source coverage for the given time period * @param datestring $start Start time for the range to update @@ -2434,83 +1827,5 @@ private function _getQueryIntervals($resolution,$dateStart,$dateEnd) { return $intervals; } - /** - * Execute Data coverage retrieves for non HEK data here. - * This extension is built into the legacy function which handles all HEK events. - * Non HEK events can add their data coverage queries here. - * This is intended to be a dispatcher, the statistics query should be coded elsewhere. - * - * @param array $eventDetails The event abbreviate that coverage is being requested for - * @param string $resolution The time bins for the data (m, 5m, 15m, 30m, h, D, W, M, Y) - * @param DateTime $startDate Start time of event range - * @param DateTime $endTime End time of event range - * @param DateTime $currentDate Current observation time. - * @return array IF RESOLUTION < 30m then The result should be an array of objects which conform to some HEK details. - * Each object in the array should look like this: - * [ - * x: unix timestamp in milliseconds of the event's start time, - * x2: unix timestamp in milliseconds of the event's end time, - * y: index of this item in the array, - * kb_archivid: unique id for this event, - * hv_labels_formatted: array of key value pairs which make up a human readable label, - * event_type: Event type abbreviation, - * frm_name: Name for the event, - * frm_specificid: Version of the recognition method, or empty string, - * event_peaktime: Peak time or null (as string in format Y-m-d H:i:s) - * event_starttime: Start time of the event, - * event_endtime: End time of the event. - * concept: The overall type of event that this is, - * modifier: 0 - * ] - * - * IF RESOLUTION 30m or Greater, then the result should look like bins of time between start and end with the number of items in each bin. - * [ - * [ - * 1680661800000, - * 0 - * ], - * [ - * 1680663600000, - * 0 - * ], - * [ - * 1680665400000, - * 0 - * ], - * [ - * 1680667200000, - * 0 - * ], - * [ - * 1680669000000, - * 0 - * ], - * ... - * ] - */ - public static function GetDataCoverageForEvent($eventDetails, $resolution, $startDate, $endDate, $currentDate): array { - $data = []; - switch ($eventDetails['event_type']) { - case "FP": - // Don't include flare prediction data in the coverage since the volume of predictions muddles the data. - // See https://github.com/Helioviewer-Project/api/pull/287 for more info - break; - } - if (in_array($resolution, ["m", "5m", "15m"])) { - $data = self::AssignColorsToData($data); - } - return $data; - } - - /** - * Iterates over the given event array and assigns the appropriate color to each event. - * @return array The data object where each event has its color assigned to it. - */ - private static function AssignColorsToData(array $data): array { - foreach ($data as &$event) { - $event['color'] = self::EVENT_COLORS[$event['event_type']]; - } - return $data; - } } ?> From dc6f6c18417c1b97d5c79beb00a55b7bc0b47b53 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 20 Mar 2026 03:32:37 +0000 Subject: [PATCH 15/49] make lineendings windows to linux for my sanity --- docroot/index.php | 576 +++++++++++++++++++++++----------------------- 1 file changed, 288 insertions(+), 288 deletions(-) diff --git a/docroot/index.php b/docroot/index.php index 2d0febc8c..cfa31a8fd 100644 --- a/docroot/index.php +++ b/docroot/index.php @@ -1,288 +1,288 @@ - - * @author Keith Hughitt - * @license http://www.mozilla.org/MPL/MPL-1.1.html Mozilla Public License 1.1 - * @link https://github.com/Helioviewer-Project/ - * - * TODO 06/28/2011 - * = Reuse database connection for statistics and other methods that need it? * - * - * TODO 01/28/2010 - * = Document getDataSources, getJP2Header, and getClosestImage methods. - * = Explain use of sourceId for faster querying. - * - * TODO 01/27/2010 - * = Add method to WebClient to print config file (e.g. for stand-alone - * web-client install to connect with) - * = Add getPlugins method to JHelioviewer module (empty function for now) - */ -require_once __DIR__.'/../vendor/autoload.php'; -require_once '../src/Config.php'; -require_once '../src/Helper/ErrorHandler.php'; -require_once '../src/Actions.php'; - -use Helioviewer\Api\Request\RequestParams; -use Helioviewer\Api\Request\RequestException; -use Helioviewer\Api\Sentry\Sentry; - -$config = new Config('../settings/Config.ini'); - -Sentry::init([ - 'environment' => HV_APP_ENV ?? 'dev', - 'sample_rate' => HV_SENTRY_SAMPLE_RATE ?? 0.1, - 'enabled' => HV_SENTRY_ENABLED ?? false, - 'dsn' => HV_SENTRY_DSN, -]); - -date_default_timezone_set('UTC'); -register_shutdown_function('shutdownFunction'); - -// Options requests are just for validating CORS -// Lets just pass them through -if ( array_key_exists('REQUEST_METHOD', $_SERVER) && $_SERVER['REQUEST_METHOD'] == 'OPTIONS' ) { - echo 'OK'; - exit; -} - -try { - // Parse request and its variables - $params = RequestParams::collect(); - -} catch (RequestException $re) { - - // Set the content type to JSON - header('Content-Type: application/json'); - - // Set the HTTP status code - http_response_code($re->getCode()); - - echo json_encode([ - 'success' => false, - 'message' => $re->getMessage(), - 'data' => [], - ]); - - // Track exception - Sentry::capture($re); - - exit; -} - -Sentry::setContext('Helioviewer', [ - 'params' => $params, - 'is_json' => false, -]); - -// Redirect to API Documentation if no API request is being made. -if ( !isset($params) || !loadModule($params) ) { - header('Location: '.HV_WEB_ROOT_URL.'/docs/v2/'); -} - -/** - * Loads the required module based on the action specified and run the - * action. - * - * @param array $params API Request parameters - * - * @return bool Returns true if the action specified is valid and was - * successfully run. - */ -function loadModule($params) { - $valid_actions = VALID_ACTIONS; - include_once HV_ROOT_DIR.'/../src/Validation/InputValidator.php'; - - - try { - // If there is no action specified OR if the given action is not VALID; then ERROR - if ( !array_key_exists('action', $params) || !array_key_exists($params['action'], $valid_actions) ) { - throw new \InvalidArgumentException('Invalid action specified.
Consult the API Documentation for a list of valid actions.'); - } else { - - //Set-up variables for rate-limiting - $prefix = HV_RATE_LIMIT_PREFIX; - //Use IP address as identifier. - $identifier = $_SERVER["REMOTE_ADDR"]; - //maximum requests a client can make before being rate limited. - $maximumRequests = HV_RATE_LIMIT_MAXIMUM_REQUESTS; - - // Instantiate rate-limiter - include HV_ROOT_DIR."/../src/Net/rate-limit/src/Exception/LimitExceeded.php"; - include HV_ROOT_DIR."/../src/Net/rate-limit/src/RedisRateLimiter.php"; - include HV_ROOT_DIR."/../src/Net/rate-limit/src/Rate.php"; - $redis = new Redis(); - $redis->connect(HV_REDIS_HOST,HV_REDIS_PORT); - $rateLimiter = new RateLimit\RedisRateLimiter($redis,$prefix); - - try { - // Rate-limit the client - // This stores the identifier in the redis database and sets an expiry time based on the temporal rate specified - // For Example: perMinute will store the $identifier with an expirty time of 60 seconds after which the $identifier is deleted from the redis database - if (HV_ENFORCE_RATE_LIMIT) { - $rateLimiter->limit($identifier, RateLimit\Rate::perMinute($maximumRequests)); - } - // Execute action - $moduleName = $valid_actions[$params['action']]; - $className = 'Module_'.$moduleName; - - // Track this request - Sentry::setTag('Module', $moduleName); - Sentry::setTag('Module.Function', $params['action']); - Sentry::setTag('Type', 'web'); - - include_once HV_ROOT_DIR.'/../src/Module/'.$moduleName.'.php'; - - $module = new $className($params); - - $module->execute(); - - // Update usage stats - $actions_to_keep_stats_for = [ - 'getClosestImage', - 'takeScreenshot', - 'postScreenshot', - 'getJPX', - 'getJPXClosestToMidPoint', - 'uploadMovieToYouTube', - 'getRandomSeed', - 'enable3D', - ]; - - // Note that in addition to the above, buildMovie requests and - // addition to getTile when the tile was already in the cache. - if ( HV_ENABLE_STATISTICS_COLLECTION && in_array($params['action'], $actions_to_keep_stats_for) ) { - - include_once HV_ROOT_DIR.'/../src/Database/Statistics.php'; - $statistics = new Database_Statistics(); - $log_param = $params['action']; - if($log_param == 'getJPXClosestToMidPoint'){ - $log_param = 'getJPX'; - } - $statistics->log($params['action']); - } - - // Log to redis on valid action - if (HV_ENABLE_STATISTICS_COLLECTION && in_array($params['action'],array_keys($valid_actions))) { - include_once HV_ROOT_DIR.'/../src/Database/Statistics.php'; - $statistics = new Database_Statistics(); - $statistics->logRedis($params['action'], $redis); - } - - } catch (LimitExceeded $exception) { - Sentry::capture($e); - } - } - } catch (\InvalidArgumentException $e) { - - // Proper response code - http_response_code(400); - - // Determine the content type of the request - $content_type = $_SERVER['CONTENT_TYPE'] ?? ''; - - - // If the request is posting JSON - if('application/json' === $content_type) { - - // Set the content type to JSON - header('Content-Type: application/json'); - - echo json_encode([ - 'success' => false, - 'message' => $e->getMessage(), - 'data' => [], - ]); - - Sentry::setContext('Helioviewer', [ - 'is_json' => true, - ]); - - Sentry::capture($e); - - exit; - - } else { - printHTMLErrorMsg($e->getMessage()); - } - - Sentry::capture($e); - } catch (Exception $e) { - printHTMLErrorMsg($e->getMessage()); - Sentry::capture($e); - } - - - return true; -} - - -/** - * Displays a human-readable HTML error message to the user - * - * @param string $msg Error message to display to the user - * - * @return void - */ -function printHTMLErrorMsg($msg) { - ?> - - - - \n"; - printf($meta, date('Y-m-d H:m:s'), $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI']); - - ?> - Helioviewer.org API - Error - - - - - -
- ' alt='Helioviewer logo'> -
- Error: -
-
-endpoint as $endpoint ) { - // Action has to be defined for documentation to work - if (array_key_exists('action', $_GET) && $endpoint['name'] == $_GET['action']) { - renderEndpoint($endpoint, $xml); - break; - } - } - footer($api_version, $api_xml_path); -?> - - - + + * @author Keith Hughitt + * @license http://www.mozilla.org/MPL/MPL-1.1.html Mozilla Public License 1.1 + * @link https://github.com/Helioviewer-Project/ + * + * TODO 06/28/2011 + * = Reuse database connection for statistics and other methods that need it? * + * + * TODO 01/28/2010 + * = Document getDataSources, getJP2Header, and getClosestImage methods. + * = Explain use of sourceId for faster querying. + * + * TODO 01/27/2010 + * = Add method to WebClient to print config file (e.g. for stand-alone + * web-client install to connect with) + * = Add getPlugins method to JHelioviewer module (empty function for now) + */ +require_once __DIR__.'/../vendor/autoload.php'; +require_once '../src/Config.php'; +require_once '../src/Helper/ErrorHandler.php'; +require_once '../src/Actions.php'; + +use Helioviewer\Api\Request\RequestParams; +use Helioviewer\Api\Request\RequestException; +use Helioviewer\Api\Sentry\Sentry; + +$config = new Config('../settings/Config.ini'); + +Sentry::init([ + 'environment' => HV_APP_ENV ?? 'dev', + 'sample_rate' => HV_SENTRY_SAMPLE_RATE ?? 0.1, + 'enabled' => HV_SENTRY_ENABLED ?? false, + 'dsn' => HV_SENTRY_DSN, +]); + +date_default_timezone_set('UTC'); +register_shutdown_function('shutdownFunction'); + +// Options requests are just for validating CORS +// Lets just pass them through +if ( array_key_exists('REQUEST_METHOD', $_SERVER) && $_SERVER['REQUEST_METHOD'] == 'OPTIONS' ) { + echo 'OK'; + exit; +} + +try { + // Parse request and its variables + $params = RequestParams::collect(); + +} catch (RequestException $re) { + + // Set the content type to JSON + header('Content-Type: application/json'); + + // Set the HTTP status code + http_response_code($re->getCode()); + + echo json_encode([ + 'success' => false, + 'message' => $re->getMessage(), + 'data' => [], + ]); + + // Track exception + Sentry::capture($re); + + exit; +} + +Sentry::setContext('Helioviewer', [ + 'params' => $params, + 'is_json' => false, +]); + +// Redirect to API Documentation if no API request is being made. +if ( !isset($params) || !loadModule($params) ) { + header('Location: '.HV_WEB_ROOT_URL.'/docs/v2/'); +} + +/** + * Loads the required module based on the action specified and run the + * action. + * + * @param array $params API Request parameters + * + * @return bool Returns true if the action specified is valid and was + * successfully run. + */ +function loadModule($params) { + $valid_actions = VALID_ACTIONS; + include_once HV_ROOT_DIR.'/../src/Validation/InputValidator.php'; + + + try { + // If there is no action specified OR if the given action is not VALID; then ERROR + if ( !array_key_exists('action', $params) || !array_key_exists($params['action'], $valid_actions) ) { + throw new \InvalidArgumentException('Invalid action specified.
Consult the API Documentation for a list of valid actions.'); + } else { + + //Set-up variables for rate-limiting + $prefix = HV_RATE_LIMIT_PREFIX; + //Use IP address as identifier. + $identifier = $_SERVER["REMOTE_ADDR"]; + //maximum requests a client can make before being rate limited. + $maximumRequests = HV_RATE_LIMIT_MAXIMUM_REQUESTS; + + // Instantiate rate-limiter + include HV_ROOT_DIR."/../src/Net/rate-limit/src/Exception/LimitExceeded.php"; + include HV_ROOT_DIR."/../src/Net/rate-limit/src/RedisRateLimiter.php"; + include HV_ROOT_DIR."/../src/Net/rate-limit/src/Rate.php"; + $redis = new Redis(); + $redis->connect(HV_REDIS_HOST,HV_REDIS_PORT); + $rateLimiter = new RateLimit\RedisRateLimiter($redis,$prefix); + + try { + // Rate-limit the client + // This stores the identifier in the redis database and sets an expiry time based on the temporal rate specified + // For Example: perMinute will store the $identifier with an expirty time of 60 seconds after which the $identifier is deleted from the redis database + if (HV_ENFORCE_RATE_LIMIT) { + $rateLimiter->limit($identifier, RateLimit\Rate::perMinute($maximumRequests)); + } + // Execute action + $moduleName = $valid_actions[$params['action']]; + $className = 'Module_'.$moduleName; + + // Track this request + Sentry::setTag('Module', $moduleName); + Sentry::setTag('Module.Function', $params['action']); + Sentry::setTag('Type', 'web'); + + include_once HV_ROOT_DIR.'/../src/Module/'.$moduleName.'.php'; + + $module = new $className($params); + + $module->execute(); + + // Update usage stats + $actions_to_keep_stats_for = [ + 'getClosestImage', + 'takeScreenshot', + 'postScreenshot', + 'getJPX', + 'getJPXClosestToMidPoint', + 'uploadMovieToYouTube', + 'getRandomSeed', + 'enable3D', + ]; + + // Note that in addition to the above, buildMovie requests and + // addition to getTile when the tile was already in the cache. + if ( HV_ENABLE_STATISTICS_COLLECTION && in_array($params['action'], $actions_to_keep_stats_for) ) { + + include_once HV_ROOT_DIR.'/../src/Database/Statistics.php'; + $statistics = new Database_Statistics(); + $log_param = $params['action']; + if($log_param == 'getJPXClosestToMidPoint'){ + $log_param = 'getJPX'; + } + $statistics->log($params['action']); + } + + // Log to redis on valid action + if (HV_ENABLE_STATISTICS_COLLECTION && in_array($params['action'],array_keys($valid_actions))) { + include_once HV_ROOT_DIR.'/../src/Database/Statistics.php'; + $statistics = new Database_Statistics(); + $statistics->logRedis($params['action'], $redis); + } + + } catch (LimitExceeded $exception) { + Sentry::capture($e); + } + } + } catch (\InvalidArgumentException $e) { + + // Proper response code + http_response_code(400); + + // Determine the content type of the request + $content_type = $_SERVER['CONTENT_TYPE'] ?? ''; + + + // If the request is posting JSON + if('application/json' === $content_type) { + + // Set the content type to JSON + header('Content-Type: application/json'); + + echo json_encode([ + 'success' => false, + 'message' => $e->getMessage(), + 'data' => [], + ]); + + Sentry::setContext('Helioviewer', [ + 'is_json' => true, + ]); + + Sentry::capture($e); + + exit; + + } else { + printHTMLErrorMsg($e->getMessage()); + } + + Sentry::capture($e); + } catch (Exception $e) { + printHTMLErrorMsg($e->getMessage()); + Sentry::capture($e); + } + + + return true; +} + + +/** + * Displays a human-readable HTML error message to the user + * + * @param string $msg Error message to display to the user + * + * @return void + */ +function printHTMLErrorMsg($msg) { + ?> + + + + \n"; + printf($meta, date('Y-m-d H:m:s'), $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI']); + + ?> + Helioviewer.org API - Error + + + + + +
+ ' alt='Helioviewer logo'> +
+ Error: +
+
+endpoint as $endpoint ) { + // Action has to be defined for documentation to work + if (array_key_exists('action', $_GET) && $endpoint['name'] == $_GET['action']) { + renderEndpoint($endpoint, $xml); + break; + } + } + footer($api_version, $api_xml_path); +?> + + + From 63108aa500e2a312ab61038e86353d0ee373f269 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 20 Mar 2026 04:17:53 +0000 Subject: [PATCH 16/49] use local api url to fix tests , using production url to report api errors --- .gitignore | 3 +- docroot/docs/index.php | 2 +- docroot/docs/v2/api_definitions.xml | 4638 +++++++++++++++++++++++++++ 3 files changed, 4641 insertions(+), 2 deletions(-) create mode 100644 docroot/docs/v2/api_definitions.xml diff --git a/.gitignore b/.gitignore index d665b3bc5..9a6fa45b1 100644 --- a/.gitignore +++ b/.gitignore @@ -67,7 +67,8 @@ docroot/statistics/bokeh/Jhv_movies docroot/statistics/bokeh/service_usage docroot/resources/JSON/celestial-objects* docroot/resources/JSON/celestial-bodies* -docroot/docs/v2 +docroot/docs/v2/* +!docroot/docs/v2/api_definitions.xml docroot/status.xml py_env py_venv_local_sunpy diff --git a/docroot/docs/index.php b/docroot/docs/index.php index 911615013..c7a6ece7b 100755 --- a/docroot/docs/index.php +++ b/docroot/docs/index.php @@ -22,7 +22,7 @@ function import_xml($api_version, &$api_xml_path, &$xml) { - $api_xml_url = sprintf("%s/docs/%s/api_definitions.xml", "https://api.helioviewer.org", $api_version); + $api_xml_url = sprintf("%s/docs/%s/api_definitions.xml", HV_WEB_ROOT_URL, $api_version); $xml = simplexml_load_file($api_xml_url); $api_xml_path = dirname(realpath(__FILE__)) . '/' . $api_version. '/api_definitions.xml'; } diff --git a/docroot/docs/v2/api_definitions.xml b/docroot/docs/v2/api_definitions.xml new file mode 100644 index 000000000..74cdcdd6f --- /dev/null +++ b/docroot/docs/v2/api_definitions.xml @@ -0,0 +1,4638 @@ + + + + <p class="description">Helioviewer.org and JHelioviewer operate off of JPEG2000 formatted image data generated from science-quality FITS files. Use the APIs below to interact directly with these intermediary JPEG2000 files.</p> + + + + <p class="description">The movie APIs can be used to generate custom + videos of up to three image datasource layers composited together. + Solar feature/event markers pins, extended region polygons, associated + text labels, and a size-of-earth scale indicator can optionally be + overlayed onto a movie.</p> + + <p class="description">Movie generation is performed asynchronously due + to the amount of resources required to complete each video. Movie + requests are queued and then processed (in the order in which they are + received) by one of several worker processes operating in parallel.</p> + + <p class="description">As a user of the API, begin by sending a + '<a class="endpoint" href="#queueMovie">queueMovie</a>' request. If + your request is successfully added to the queue, you will receive a + response containing a unique movie identifier. This identifier can be + used to monitor the status of your movie via '<a class="endpoint" + href="#getMovieStatus">getMovieStatus</a>' and then download or play it + (via '<a class="endpoint" href="#downloadMovie">downloadMovie</a>' or + '<a class="endpoint" href="#playMovie">playMovie</a>') once its status + marked as completed.</p> + + <p class="description">Movies may contain between 10 and 300 frames. The + movie frames are chosen by matching the closest image available at + each step within the specified range of dates, and are automatically + selected by the API. The region to be included in the movie may be + specified using either the top-left and bottom-right coordinates in + arc-seconds, or a center point in arc-seconds and a width and + height in pixels. See the <a class="appendix" href="#appendix_coordinates"> + Coordinates Appendix</a> for more infomration about working with the + coordinates used by Helioviewer.org.</p> + + + + <p class="description">The screenshot APIs can be used to generate custom videos of up to three image datasource layers composited together. Solar feature/event markers pins, extended region polygons, associated text labels, and a size-of-earth scale indicator can optionally be overlayed onto a movie.</p> + + + + + <p class="description">Helioviewer.org's solar features and events annotation layer is powered by the <a href="http://www.lmsal.com/hek/" target="_blank">Heliophysics Events Knowledgebase</a> (HEK) provided by the <a href="http://www.lmsal.com/" target="_blank">Lockheed Martin Solar &amp; Astrophysics Laboratory</a> (LMSAL).</p> + + <p class="description">Consult LMSAL's <a href="http://www.lmsal.com/hek/api.html" target="_blank">HEK API Documentation</a> for more information.</p> + + + + + + + + + + + + + /v2/checkYouTubeAuth/ + + + + + + + + + + + + + /v2/queueMovie/ + + + + + + + + + + + + + or
[3,1,100,2,60,1,2010-03-01T12:12:12.000Z]]]>
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + earth + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 - Original size;
1 - 720p (1280 x 720, HD Ready);
2 - 1080p (1920 x 1080, Full HD);
3 - 1440p (2560 x 1440, Quad HD);
4 - 2160p (3840 x 2160, 4K or Ultra HD).]]>
+ +
+ + + + + + + + + + + + +
+ + + + +
+ + /v2/reQueueMovie/ + + + + + + + + + + + + + + /v2/getMovieStatus/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /v2/downloadMovie/ + + + + + + + + + + + + + + + + + + + + + /v2/takeScreenshot/ + + + + + + + + + + + + + or
[3,1,100,2,60,1,2010-03-01T12:12:12.000Z]]]>
+
+ + + + + + + + + + + + + + + + + earth + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+ + /v2/downloadScreenshot/ + + + + + + + + + + + + + /v2/getClosestImage/ + + + + + + + + + + + + + + + + + + + + + + /v2/getDataSources/ + + + + + + + + + + + + + + + + + + + + + + + + + + /v2/getJP2Image/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /v2/getJP2Header/ + + + + + + + + + + + + + + + + + /v2/getJPX/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /v2/getJPXClosestToMidPoint/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /v2/getNewsFeed/ + + + + + + + + + + + + + /v2/getTile/ + + + + + + + + + + + + + + + + + + + + 0 - Display regular image;
0 - Running difference image;
0 - Base difference image.]]>
+ +
+ + + + + + 0 - Seconds;
1 - Minutes;
2 - Hours;
3 - Days;
4 - Weeks;
5 - Month;
6 - Years.]]>
+ +
+ + + + +
+ + + +
+ + /v2/getYouTubeAuth/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /v2/playMovie/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /v2/shortenURL/ + + + + + + + + + + + + + + + + + + /v2/uploadMovieToYouTube/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /v2/getUserVideos/ + + + + + + + + + + + + + + + + + + + + + + + + + + + Revoke access for Helioviewer.org in your Google settings page and try again.", + "errno": 42 +}]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
From 50ab7df190e7ba6d92650a1e0b7005e346b02bd7 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 20 Mar 2026 05:41:45 +0000 Subject: [PATCH 17/49] bring abstract module to reduce code dublication, also remove params pointer array, just use array to use copy value instead of reference --- src/Module/AbstractModule.php | 8 ++++++++ src/Module/JHelioviewer.php | 14 -------------- src/Module/Movies.php | 12 ------------ src/Module/SolarBodies.php | 7 ++----- src/Module/SolarEvents.php | 15 --------------- src/Module/WebClient.php | 15 --------------- 6 files changed, 10 insertions(+), 61 deletions(-) diff --git a/src/Module/AbstractModule.php b/src/Module/AbstractModule.php index 7261884c1..c0a576a5b 100644 --- a/src/Module/AbstractModule.php +++ b/src/Module/AbstractModule.php @@ -13,6 +13,14 @@ abstract class AbstractModule { + protected $_params; + protected $_options; + + public function __construct($params) { + $this->_params = $params; + $this->_options = array(); + } + /** * Send a JSON response with status code and message * diff --git a/src/Module/JHelioviewer.php b/src/Module/JHelioviewer.php index 9d1f81da0..1e8c09879 100644 --- a/src/Module/JHelioviewer.php +++ b/src/Module/JHelioviewer.php @@ -19,22 +19,8 @@ class Module_JHelioviewer extends AbstractModule implements ModuleInterface { - private $_params; - private $_options; private $_sourceInfo; - /** - * Create a JHelioviewer module instance - * - * @param array &$params API Request parameters. - * - * @return void - */ - public function __construct(&$params) { - $this->_params = $params; - $this->_options = array(); - } - /** * Validate and execute the requested API action * diff --git a/src/Module/Movies.php b/src/Module/Movies.php index 82c1ff394..64a9fd9bb 100644 --- a/src/Module/Movies.php +++ b/src/Module/Movies.php @@ -20,18 +20,6 @@ class Module_Movies extends AbstractModule implements ModuleInterface { const YOUTUBE_THUMBNAIL_FORMAT = "https://i.ytimg.com/vi/{VideoID}/{Quality}default.jpg"; - private $_params; - private $_options; - - /** - * Movie module constructor - * - * @param mixed &$params API request parameters - */ - public function __construct(&$params) { - $this->_params = $params; - $this->_options = array(); - } /** * execute diff --git a/src/Module/SolarBodies.php b/src/Module/SolarBodies.php index 0cf58399d..354ce0ec1 100644 --- a/src/Module/SolarBodies.php +++ b/src/Module/SolarBodies.php @@ -12,8 +12,6 @@ */ class Module_SolarBodies extends AbstractModule implements ModuleInterface { - private $_params; - private $_options; private $_version; private $_observers; private $_bodies; @@ -26,9 +24,8 @@ class Module_SolarBodies extends AbstractModule implements ModuleInterface { * * @param mixed &$params API request parameters */ - public function __construct(&$params) { - $this->_params = $params; - $this->_options = array(); + public function __construct($params) { + parent::__construct($params); // version number - used to reset all client cookies when this module changes significantly $this->_version = 3; // list of observers - add new observers here diff --git a/src/Module/SolarEvents.php b/src/Module/SolarEvents.php index 11f490471..a286b8dc3 100644 --- a/src/Module/SolarEvents.php +++ b/src/Module/SolarEvents.php @@ -20,21 +20,6 @@ class Module_SolarEvents extends AbstractModule implements ModuleInterface { - private $_params; - private $_options; - - /** - * Constructor - * - * @param mixed &$params API Request parameters, including the action name. - * - * @return void - */ - public function __construct(&$params) { - $this->_params = $params; - $this->_options = array(); - } - /** * execute * diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index be12e69e4..d1a682f35 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -23,21 +23,6 @@ class Module_WebClient extends AbstractModule implements ModuleInterface { - private $_params; - private $_options; - - /** - * Constructor - * - * @param mixed &$params API Request parameters, including the action name. - * - * @return void - */ - public function __construct(&$params) { - $this->_params = $params; - $this->_options = array(); - } - /** * execute * From 78cf7767d6948ec617def7b767acaf8f29126df7 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 20 Mar 2026 05:46:17 +0000 Subject: [PATCH 18/49] remove trailing space , remove legacy event-interface test, add connetion timeout to php test client, makes connection url unbearable --- src/Event/EventsStateManager.php | 2 +- .../unit_tests/helpers/EventInterfaceTest.php | 29 ------------------- .../regression/SentryIssue1Test.php | 3 +- 3 files changed, 3 insertions(+), 31 deletions(-) delete mode 100644 tests/unit_tests/helpers/EventInterfaceTest.php diff --git a/src/Event/EventsStateManager.php b/src/Event/EventsStateManager.php index 15b685365..6aa5241ea 100644 --- a/src/Event/EventsStateManager.php +++ b/src/Event/EventsStateManager.php @@ -12,7 +12,7 @@ namespace Helioviewer\Api\Event; -class EventsStateManager +class EventsStateManager { // internal events state original public array $events_state; diff --git a/tests/unit_tests/helpers/EventInterfaceTest.php b/tests/unit_tests/helpers/EventInterfaceTest.php deleted file mode 100644 index 68b4b8b17..000000000 --- a/tests/unit_tests/helpers/EventInterfaceTest.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ - -use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\TestCase; - -require_once HV_ROOT_DIR . "/../src/Helper/EventInterface.php"; - -final class EventInterfaceTest extends TestCase { - /** - * Issue: https://github.com/Helioviewer-Project/helioviewer.org/issues/626 - * Error was caused by Flare Scoreboard data having invalid coordinates. - * This test gets the events which were causing the error to verify that - * no error occurs anymore. - */ - #[Group('event interface')] - public function testGetEventsOnDateWithQuestionableData(): void { - // Original error was that an exception was thrown. - $this->expectNotToPerformAssertions(); - Helper_EventInterface::GetEvents( - new DateTimeImmutable("2015-11-03"), - new DateInterval('P1D'), - new DateTimeImmutable('2015-11-03T15:00:00'), - ); - } -} diff --git a/tests/unit_tests/regression/SentryIssue1Test.php b/tests/unit_tests/regression/SentryIssue1Test.php index 8b763c4ce..2dfa6419c 100644 --- a/tests/unit_tests/regression/SentryIssue1Test.php +++ b/tests/unit_tests/regression/SentryIssue1Test.php @@ -20,7 +20,8 @@ public function testItShouldDumpProperResponseCodeAndReasonPhraseIfThereIsNoActi // Send a GET request to the specified URL $response = $client->get(HV_LOCAL_TEST_URL, [ - 'http_errors' => false + 'http_errors' => false, + 'connect_timeout' => 1 ]); // Assert Status code and Reason Phrase From 09aff0918a914855b633afbf04e72c9df943ecc6 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 23 Mar 2026 16:44:06 +0000 Subject: [PATCH 19/49] add missing inclusion of namespaces --- src/Image/Composite/HelioviewerCompositeImage.php | 4 ++-- src/Module/SolarEvents.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index c754cda95..d96a733bc 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -20,8 +20,8 @@ require_once HV_ROOT_DIR.'/../src/Module/SolarBodies.php'; use Helioviewer\Api\Sentry\Sentry; -use Helioviewer\Api\Event\EventsApi; -use Helioviewer\Api\Event\EventsApiException; +use Helioviewer\Api\Event\Api\EventsApi; +use Helioviewer\Api\Event\Api\EventsApiException; class Image_Composite_HelioviewerCompositeImage { diff --git a/src/Module/SolarEvents.php b/src/Module/SolarEvents.php index a286b8dc3..b5e924153 100644 --- a/src/Module/SolarEvents.php +++ b/src/Module/SolarEvents.php @@ -15,8 +15,8 @@ use Helioviewer\Api\Module\AbstractModule; use Helioviewer\Api\Module\ModuleInterface; use Helioviewer\Api\Sentry\Sentry; -use Helioviewer\Api\Event\EventsApi; -use Helioviewer\Api\Event\EventsApiException; +use Helioviewer\Api\Event\Api\EventsApi; +use Helioviewer\Api\Event\Api\EventsApiException; class Module_SolarEvents extends AbstractModule implements ModuleInterface { From a6ad7c125bff910065d61e4af31f15ff1289c4ee Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 23 Mar 2026 16:47:07 +0000 Subject: [PATCH 20/49] move movies to work with new EventsApi per frame, still way to go --- .../Composite/HelioviewerCompositeImage.php | 42 +++++++------------ 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index d96a733bc..227fc798e 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -100,7 +100,7 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi 'eclipse' => false, 'moon' => false ); - + $options = array_replace($defaults, $options); $this->width = $roi->getPixelWidth(); @@ -587,35 +587,23 @@ private function _addEventLayer($imagickImage) { $markerPinPixelOffsetX = 12; $markerPinPixelOffsetY = 38; - require_once HV_ROOT_DIR.'/../src/Event/HEKAdapter.php'; - require_once HV_ROOT_DIR . "/../src/Helper/EventInterface.php"; - - // Collect events from all data sources. - // Collect all HEK events - $hek = new Event_HEKAdapter(); - $event_categories = $hek->getNormalizedEvents($this->date, Array()); - - $events_api_sources = ["CCMC", "RHESSI"]; - + // Fetch events from all sources via EventsApi $observationTime = new DateTimeImmutable($this->date); - $startDate = $observationTime->sub(new DateInterval("PT12H")); - $length = new DateInterval("P1D"); - - // Collect CCMC events if any - try { - - $eventsApi = new EventsApi(); - $event_categories = array_merge($event_categories, $eventsApi->getEventsForSourceLegacy($observationTime, "CCMC")); - // if there is no error only left is RHESSI to collect - $events_api_sources = ["RHESSI"]; - - } catch (EventsApiException $e) { - Sentry::capture($e); + $allSources = ['CCMC', 'HEK', 'RHESSI']; + $eventsApi = new EventsApi(); + $event_categories = []; + + foreach ($allSources as $source) { + try { + $sourceData = $eventsApi->getEventsForSourceLegacy($observationTime, $source); + $event_categories = array_merge($event_categories, $sourceData); + } catch (EventsApiException $e) { + // Already captured to Sentry by EventsApi + } catch (\Exception $e) { + Sentry::capture($e); + } } - // Collect RHESSI events - $event_categories = array_merge($event_categories, Helper_EventInterface::GetEvents($startDate, $length, $observationTime, $events_api_sources)); - // Lay down all relevant event REGIONS first $events_to_render = []; $events_manager = $this->eventsManager; From ac024d029ec48d7116fe9c023e9aa5f0e2b7212c Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 23 Mar 2026 21:51:48 +0000 Subject: [PATCH 21/49] fix double sentry sending --- src/Module/SolarEvents.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Module/SolarEvents.php b/src/Module/SolarEvents.php index b5e924153..c99a2b90f 100644 --- a/src/Module/SolarEvents.php +++ b/src/Module/SolarEvents.php @@ -211,6 +211,8 @@ public function events() { $sourceData = $eventsApi->getEventsForSourceLegacy($observationTime, $source); $data = array_merge($data, $sourceData); } catch (EventsApiException $e) { + return $this->_sendResponse(500, 'Internal Server Error', 'Failed to fetch events from ' . $source); + } catch (\Exception $e) { Sentry::capture($e); return $this->_sendResponse(500, 'Internal Server Error', 'Failed to fetch events from ' . $source); } From 87ac9b356686f0580dc685ba9ecb2c4cee5fbc04 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 24 Mar 2026 03:29:31 +0000 Subject: [PATCH 22/49] centralize eventsapi and implement timeline for events --- src/Event/EventSelections.php | 177 +++++++++++++ src/Event/EventsTimeline.php | 241 ++++++++++++++++++ .../Composite/HelioviewerCompositeImage.php | 7 +- src/Module/AbstractModule.php | 10 + src/Module/SolarEvents.php | 4 +- src/Module/WebClient.php | 149 +++-------- 6 files changed, 473 insertions(+), 115 deletions(-) create mode 100644 src/Event/EventSelections.php create mode 100644 src/Event/EventsTimeline.php diff --git a/src/Event/EventSelections.php b/src/Event/EventSelections.php new file mode 100644 index 000000000..b5b4ac331 --- /dev/null +++ b/src/Event/EventSelections.php @@ -0,0 +1,177 @@ + + * @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' => 'ExplosiveEvent', + 'PB' => 'ProminenceBubble', + 'PT' => 'PeacockTail', + 'EP' => 'SEPs', + 'IC' => 'ICMEs', + 'SR' => 'SIRs', + // 'UNK' => 'Unknown', // Not in events API + ], + 'CCMC' => [ + 'C3' => 'DONKI', + 'FP' => 'Solar Flare Predictions', + ], + 'RHESSI' => [ + 'F2' => 'Solar Flares', + ], + ]; + + 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 + */ + 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)) { + $event_strings = explode("],[", trim(stripslashes($events_state_string), '][')); + + // 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/EventsTimeline.php b/src/Event/EventsTimeline.php new file mode 100644 index 000000000..50bc12f4b --- /dev/null +++ b/src/Event/EventsTimeline.php @@ -0,0 +1,241 @@ + + * @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 DateTime; +use DateInterval; +use DatePeriod; +use InvalidArgumentException; +use Exception; +use Helioviewer\Api\Event\Api\EventsApi; + +require_once HV_ROOT_DIR . '/../src/Helper/HelioviewerEvents.php'; + +class EventsTimeline +{ + // Threshold (ms) => resolution mapping for timeline display + private const RESOLUTION_THRESHOLDS = [ + 86400000 => 'm', // < 1 day + 172800000 => '30m', // < 2 days + 864000000 => 'h', // < 10 days + 16070400000 => 'D', // < ~6 months + 40176000000 => 'W', // < ~15 months + 157680000000 => 'M', // < ~5 years + ]; + + private \Helper_HelioviewerEvents $events; + private EventSelections $eventSelections; + private EventsApi $eventsApi; + private int $startMs; // milliseconds + private int $endMs; // milliseconds + private int $currentMs; // milliseconds + private string $resolution; + + /** + * @throws InvalidArgumentException If parameters are invalid + */ + public function __construct(string $eventLayers, $startTimestamp, $endTimestamp, $currentTimestamp, ?EventsApi $eventsApi = null) + { + $this->eventsApi = $eventsApi ?? new EventsApi(); + // Validate all three timestamps are provided and positive integers (in milliseconds) + $startTimestamp = filter_var($startTimestamp, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + $endTimestamp = filter_var($endTimestamp, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + $currentTimestamp = filter_var($currentTimestamp, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + + if ($startTimestamp === false || $endTimestamp === false || $currentTimestamp === false) { + throw new InvalidArgumentException('startTimestamp, endTimestamp, and currentTimestamp must be positive integer timestamps in milliseconds'); + } + + // Validate chronological order: start <= end + if ($startTimestamp > $endTimestamp) { + throw new InvalidArgumentException('startTimestamp must be less than or equal to endTimestamp'); + } + + $this->startMs = $startTimestamp; + $this->endMs = $endTimestamp; + $this->currentMs = $currentTimestamp; + + $this->resolution = current(array_filter(self::RESOLUTION_THRESHOLDS, fn($t) => $this->endMs - $this->startMs < $t, ARRAY_FILTER_USE_KEY)) ?: 'Y'; + $this->events = new \Helper_HelioviewerEvents($eventLayers); + $this->eventSelections = EventSelections::buildFromLegacyEventStrings($eventLayers); + } + + public function timeline(): string + { + if ($this->resolution === 'm') { + return $this->getMinuteCoverage(); + } + return $this->getAggregatedCoverage(); + } + + /** + * Get aggregated event coverage using EventsApi distributions endpoint + */ + private function getAggregatedCoverage(): string + { + // Original request range (in milliseconds) + $startMs = $this->startMs; + $endMs = $this->endMs; + + // Expand range by distance on both sides for smooth scrolling buffer + $distance = $endMs - $startMs; + $extendedStartMs = $startMs - $distance; + $extendedEndMs = $endMs + $distance; + + // Convert to seconds for API call + $extendedStartSec = intval($extendedStartMs / 1000); + $extendedEndSec = intval($extendedEndMs / 1000); + + // Get selection paths from EventSelections + $paths = iterator_to_array($this->eventSelections); + + if (empty($paths)) { + return json_encode([]); + } + + $results = []; + + // Call the Events API + $response = $this->eventsApi->getDistributions( + $this->resolution, + $extendedStartSec, + $extendedEndSec, + $paths + ); + + $event_types = $response['event_types'] ?? []; + $buckets = $response['buckets'] ?? []; + + foreach ($event_types as $et) { + $results[$et] = [ + 'data' => [], + 'event_type' => $et, + 'res' => $this->resolution, + 'showInLegend' => true, + ]; + } + + foreach ($buckets as $bucket) { + $bucketStartMs = $bucket['start'] * 1000; + $default_counts = array_fill_keys($event_types, 0); + + $counts = $bucket['counts'] ?? []; + $counts = array_merge($default_counts, $counts); + + foreach ($counts as $eventType => $count) { + $results[$eventType]['data'][] = [$bucketStartMs, (int) $count]; + } + } + + ksort($results); + return json_encode(array_values($results)); + } + + /** + * Get minute-level event coverage using EventsApi events endpoint + */ + private function getMinuteCoverage(): string + { + // Calculate extended time range (3x visible range for smooth scrolling) + $distance = $this->endMs - $this->startMs; + $extendedStartMs = $this->startMs - $distance; + $extendedEndMs = $this->endMs + $distance; + + // Convert to seconds for API call + $fromTimestamp = intval($extendedStartMs / 1000); + $toTimestamp = intval($extendedEndMs / 1000); + + // Get selection paths from EventSelections + $paths = iterator_to_array($this->eventSelections); + + // Fetch events from the Events API + $response = $this->eventsApi->getEventsInRange($fromTimestamp, $toTimestamp, $paths); + $events = $response['events'] ?? []; + + // Group events by event_type and add x, x2 for timeline display + $results = []; + + foreach ($events as $event) { + $eventType = $event['event_type'] ?? 'UNK'; + + // If this event_type not seen yet, create a new group + if (!isset($results[$eventType])) { + $results[$eventType] = [ + 'data' => [], + 'event_type' => $eventType, + 'res' => 'm', + 'showInLegend' => true + ]; + } + + // Convert event times to milliseconds for x and x2 + $timeStart = strtotime($event['event_starttime']) * 1000; + $timeEnd = strtotime($event['event_endtime']) * 1000; + + // Clamp to extended range + if ($extendedStartMs > $timeStart) { + $timeStart = $extendedStartMs; + } + if ($extendedEndMs < $timeEnd) { + $timeEnd = $extendedEndMs; + } + + // Add x and x2 to event + $event['x'] = $timeStart; + $event['x2'] = $timeEnd; + $event['y'] = 0; // Temporary, will be set by swim lane algorithm + + // Add event to the data array for this event_type + $results[$eventType]['data'][] = $event; + } + + // SWIM LANE ALGORITHM - Stack overlapping events + // Events that overlap in time are placed in different "swim lanes" (y values) + $i = 1; // Next available swim lane number + $levels = []; // Tracks events in each lane: [lane => [events...]] + + foreach ($results as $eventType => $series) { + $data = []; + + foreach ($series['data'] as $event) { + $placed = false; + + // Try to fit in an existing lane + foreach ($levels as $row => $rowEvents) { + $last = end($rowEvents); + // If new event starts after last event ends, it fits here + if ($event['x'] >= $last['x2']) { + $event['y'] = $row; + $levels[$row][] = $event; + $data[] = $event; + $placed = true; + break; + } + } + + // No existing lane works, create a new one + if (!$placed) { + $levels[$i] = [$event]; + $event['y'] = $i; + $data[] = $event; + $i++; + } + } + + $results[$eventType]['data'] = $data; + } + + // Convert to indexed array + return json_encode(array_values($results)); + } +} diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index 227fc798e..faead5742 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -98,7 +98,8 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi 'switchSources' => false, 'grayscale' => false, 'eclipse' => false, - 'moon' => false + 'moon' => false, + 'eventsApi' => null ); $options = array_replace($defaults, $options); @@ -108,6 +109,7 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi $this->imageScale = $roi->imageScale(); $this->db = $options['database'] ? $options['database'] : new Database_ImgIndex(); + $this->eventsApi = $options['eventsApi'] ?? new EventsApi(); $this->layers = $layers; $this->eventsManager = $eventsManager; $this->movieIcons = $movieIcons; @@ -590,12 +592,11 @@ private function _addEventLayer($imagickImage) { // Fetch events from all sources via EventsApi $observationTime = new DateTimeImmutable($this->date); $allSources = ['CCMC', 'HEK', 'RHESSI']; - $eventsApi = new EventsApi(); $event_categories = []; foreach ($allSources as $source) { try { - $sourceData = $eventsApi->getEventsForSourceLegacy($observationTime, $source); + $sourceData = $this->eventsApi->getEventsForSourceLegacy($observationTime, $source); $event_categories = array_merge($event_categories, $sourceData); } catch (EventsApiException $e) { // Already captured to Sentry by EventsApi diff --git a/src/Module/AbstractModule.php b/src/Module/AbstractModule.php index c0a576a5b..4267f607d 100644 --- a/src/Module/AbstractModule.php +++ b/src/Module/AbstractModule.php @@ -11,16 +11,26 @@ namespace Helioviewer\Api\Module; +use Helioviewer\Api\Event\Api\EventsApi; + abstract class AbstractModule { protected $_params; protected $_options; + private ?EventsApi $_eventsApi = null; public function __construct($params) { $this->_params = $params; $this->_options = array(); } + protected function eventsApi(): EventsApi { + if ($this->_eventsApi === null) { + $this->_eventsApi = new EventsApi(); + } + return $this->_eventsApi; + } + /** * Send a JSON response with status code and message * diff --git a/src/Module/SolarEvents.php b/src/Module/SolarEvents.php index c99a2b90f..fe4ae03c4 100644 --- a/src/Module/SolarEvents.php +++ b/src/Module/SolarEvents.php @@ -15,7 +15,6 @@ use Helioviewer\Api\Module\AbstractModule; use Helioviewer\Api\Module\ModuleInterface; use Helioviewer\Api\Sentry\Sentry; -use Helioviewer\Api\Event\Api\EventsApi; use Helioviewer\Api\Event\Api\EventsApiException; class Module_SolarEvents extends AbstractModule implements ModuleInterface { @@ -203,12 +202,11 @@ public function events() { } // Fetch events from each source via EventsApi - $eventsApi = new EventsApi(); $data = []; foreach ($sources as $source) { try { - $sourceData = $eventsApi->getEventsForSourceLegacy($observationTime, $source); + $sourceData = $this->eventsApi()->getEventsForSourceLegacy($observationTime, $source); $data = array_merge($data, $sourceData); } catch (EventsApiException $e) { return $this->_sendResponse(500, 'Internal Server Error', 'Failed to fetch events from ' . $source); diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index d1a682f35..66258cb7f 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -19,6 +19,8 @@ use Helioviewer\Api\Module\AbstractModule; use Helioviewer\Api\Module\ModuleInterface; use Helioviewer\Api\Event\EventsStateManager; +use Helioviewer\Api\Event\EventsTimeline; +use Helioviewer\Api\Event\Api\EventsApiException; use Helioviewer\Api\Sentry\Sentry; class Module_WebClient extends AbstractModule implements ModuleInterface { @@ -955,9 +957,25 @@ public function getDataCoverage() { if (!empty($this->_options['imageLayers'])) { return $this->getDataCoverageForLayers(); } else if (!empty($this->_options['eventLayers'])) { - return $this->getDataCoverageForEvents(); - } else { - return $this->_sendResponse(400, 'eventLayers or imageLayers needs to be set for this endpoint to work in API', ''); + try { + $eventTimeline = new EventsTimeline( + $this->_options['eventLayers'], + $this->_options['startDate'] ?? null, + $this->_options['endDate'] ?? null, + $this->_options['currentDate'] ?? null, + $this->eventsApi() + ); + + $this->_printJSON($eventTimeline->timeline()); + } 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()); + } } } @@ -972,131 +990,44 @@ public function getDataCoverageForLayers() { // Parse image layers (e.g., "[SDO,AIA,171,1,100]") $layers = new Helper_HelioviewerLayers($this->_options['imageLayers']); - // Parse and validate time parameters - $timeParams = $this->_parseTimeParameters(); - if ($timeParams === null) { - return $this->_sendResponse(400, 'Invalid time parameters', 'startDate, endDate, and currentDate must be numeric timestamps in milliseconds'); - } - - // Determine resolution based on time range - $resolution = $this->_calculateResolution($timeParams['range']); - - // Fetch and return coverage data - $statistics = new Database_Statistics(); - $this->_printJSON( - $statistics->getDataCoverage( - $layers, - $resolution, - $timeParams['dateStart'], - $timeParams['dateEnd'] - ) - ); - } - - /** - * Returns data coverage for EVENT layers. - * Queries the events/events_coverage tables for event availability data. - * TODO: Migrate to use EventsApi instead of local database tables - */ - public function getDataCoverageForEvents() { - include_once HV_ROOT_DIR.'/../src/Helper/HelioviewerEvents.php'; - include_once HV_ROOT_DIR.'/../src/Database/Statistics.php'; - - // Parse event layers (e.g., "[AR,all,1],[FL,all,1]") - $events = new Helper_HelioviewerEvents($this->_options['eventLayers']); - - // Parse and validate time parameters - $timeParams = $this->_parseTimeParameters(); - if ($timeParams === null) { - return $this->_sendResponse(400, 'Invalid time parameters', 'startDate, endDate, and currentDate must be numeric timestamps in milliseconds'); - } - - // Determine resolution based on time range - $resolution = $this->_calculateResolution($timeParams['range']); - - // For events, force minute resolution for ranges < 24 hours - if ($timeParams['range'] < 24 * 60 * 60 * 1000) { - $resolution = 'm'; - } - - // Events don't support 5m/15m resolution - upgrade to 30m - // (events_coverage table only has 30m, 1H, 1D, 1W, 1M, 1Y buckets) - if ($resolution == '5m' || $resolution == '15m') { - $resolution = '30m'; - } + // Validate and parse time parameters (inline) + $start = $this->_options['startDate'] ?? null; + $end = $this->_options['endDate'] ?? null; - // Fetch and return coverage data - $statistics = new Database_Statistics(); - $this->_printJSON( - $statistics->getDataCoverageEvents( - $events, - $resolution, - $timeParams['dateStart'], - $timeParams['dateEnd'], - $timeParams['dateCurrent'] - ) - ); - } - - /** - * Parses and validates time parameters from request options. - * All timestamps are expected in MILLISECONDS (JavaScript convention). - * - * @return array|null Returns null if validation fails, otherwise returns: - * array{ - * start: int, - * end: int, - * current: int, - * range: int, - * dateStart: DateTime, - * dateEnd: DateTime, - * dateCurrent: DateTime - * } - */ - private function _parseTimeParameters(): ?array { - // Validate numeric format - $start = @$this->_options['startDate']; if ($start && !preg_match('/^[0-9]+$/', $start)) { - return null; + return $this->_sendResponse(400, 'Invalid time parameters', 'startDate must be numeric timestamp in milliseconds'); } - $end = @$this->_options['endDate']; if ($end && !preg_match('/^[0-9]+$/', $end)) { - return null; - } - $current = @$this->_options['currentDate']; - if ($current && !preg_match('/^[0-9]+$/', $current)) { - return null; + return $this->_sendResponse(400, 'Invalid time parameters', 'endDate must be numeric timestamp in milliseconds'); } - // Defaults: start=0, end=now, current=0 if (!$start) $start = 0; if (!$end) $end = time() * 1000; - if (!$current) $current = 0; - // Calculate range $range = $end - $start; - // Convert to DateTime objects (from milliseconds to seconds) $dateEnd = new DateTime(); $dateEnd->setTimestamp(intval($end / 1000)); $dateStart = new DateTime(); $dateStart->setTimestamp(intval($start / 1000)); - $dateCurrent = new DateTime(); - $dateCurrent->setTimestamp(intval($current / 1000)); - - return [ - 'start' => $start, - 'end' => $end, - 'current' => $current, - 'range' => $range, - 'dateStart' => $dateStart, - 'dateEnd' => $dateEnd, - 'dateCurrent' => $dateCurrent - ]; + // Calculate resolution for images + $resolution = $this->_calculateResolution($range); + + // Fetch and return coverage data + $statistics = new Database_Statistics(); + $this->_printJSON( + $statistics->getDataCoverage( + $layers, + $resolution, + $dateStart, + $dateEnd + ) + ); } + /** * Retrieves the latest usage statistics from the database */ From ec842f2d39ee2fcc80462ce10b4e26b8337742be Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 27 Mar 2026 17:26:49 +0000 Subject: [PATCH 23/49] add additional destructs to be sure, we are closing all mysql connections --- src/Database/ImgIndex.php | 9 +++++++++ src/Database/MovieDatabase.php | 9 +++++++++ src/Database/Statistics.php | 9 +++++++++ 3 files changed, 27 insertions(+) diff --git a/src/Database/ImgIndex.php b/src/Database/ImgIndex.php index bb50da539..51b7562b4 100644 --- a/src/Database/ImgIndex.php +++ b/src/Database/ImgIndex.php @@ -40,6 +40,15 @@ protected function _dbConnect() { } } + /** + * Deconstructor should be executed when this class instance is not referenced + * + * @return void + */ + public function __destruct() { + $this->_dbConnection = false; + } + /** * Insert a new screenshot into the `screenshots` table. * diff --git a/src/Database/MovieDatabase.php b/src/Database/MovieDatabase.php index 4de25ae18..a6c1c5948 100644 --- a/src/Database/MovieDatabase.php +++ b/src/Database/MovieDatabase.php @@ -39,6 +39,15 @@ private function _dbConnect() { } } + /** + * Deconstructor should be executed when this class instance is not referenced + * + * @return void + */ + public function __destruct() { + $this->_dbConnection = false; + } + /** * Insert a new movie entry into the `movies` table and returns its * identifier. diff --git a/src/Database/Statistics.php b/src/Database/Statistics.php index 8bfeb3ed2..a07ea8721 100644 --- a/src/Database/Statistics.php +++ b/src/Database/Statistics.php @@ -32,6 +32,15 @@ public function __construct() { $this->_dbConnection = new Database_DbConnection(); } + /** + * Deconstructor should be executed when this class instance is not referenced + * + * @return void + */ + public function __destruct() { + $this->_dbConnection = false; + } + /** * Gets device information from the user agent */ From e5065c5c63a7605e2068956d0f8c7d033caa36b8 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 27 Mar 2026 17:41:56 +0000 Subject: [PATCH 24/49] continue to use single instance of EventsApi --- src/Module/WebClient.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index 66258cb7f..6b56c83a7 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -530,7 +530,7 @@ public function postScreenshot() $scaleY, $json_params['date'], $roi, - $json_params + array_merge($json_params, ['eventsApi' => $this->eventsApi()]) ); // Display screenshot @@ -627,7 +627,7 @@ public function takeScreenshot() { $scaleY, $this->_params['date'], $roi, - $this->_options + array_merge($this->_options, ['eventsApi' => $this->eventsApi()]) ); // Display screenshot @@ -738,7 +738,7 @@ public function reTakeScreenshot($screenshotId) { $metaData['scaleY'], $metaData['observationDate'], $roi, - $options + array_merge($options, ['eventsApi' => $this->eventsApi()]) ); } @@ -1352,7 +1352,8 @@ public function getEclipseImage() { [ 'grayscale' => true, 'eclipse' => true, - 'moon' => $this->_options['moon'] + 'moon' => $this->_options['moon'], + 'eventsApi' => $this->eventsApi() ] ); $screenshot->display(); From c43bf18fe3f89f6340f5d5412e8a965dd7225ae5 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 27 Mar 2026 19:41:32 +0000 Subject: [PATCH 25/49] movie making, use eventsapi batchEvents query endpoint to receive all rotated events for all frames, only 1 requst to calculate all points --- src/Event/Api/EventsApi.php | 167 +++++++++++++++++- src/Event/Api/EventsApiInterface.php | 11 ++ src/Event/EventsStateManager.php | 14 +- .../Composite/HelioviewerCompositeImage.php | 26 +-- src/Movie/HelioviewerMovie.php | 22 +++ 5 files changed, 222 insertions(+), 18 deletions(-) diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index 7cea1242b..668b610c8 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -8,16 +8,42 @@ use Helioviewer\Api\Sentry\Sentry; use Helioviewer\Api\Sentry\ClientInterface as SentryClientInterface; +/** + * 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. + * + * All methods capture errors to Sentry and throw EventsApiException on failure. + */ class EventsApi implements EventsApiInterface { + /** Known event sources */ + public const VALID_SOURCES = ['HEK', 'CCMC', 'RHESSI']; + private ClientInterface $client; private SentryClientInterface $sentry; + /** + * 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. + * Creates an HTTP client configured with the Events API base URL and timeouts. + * Falls back to defaults if HV_EVENTS_API_URL or HV_EVENTS_API_TIMEOUT are not defined. * - * @param ClientInterface|null $client Optional Guzzle client; if not provided, a new Client is created. - * @param SentryClientInterface|null $sentry Optional Sentry client; if not provided, uses the global Sentry client. + * @param ClientInterface|null $client Optional Guzzle client for testing + * @param SentryClientInterface|null $sentry Optional Sentry client for testing */ public function __construct(ClientInterface $client = null, SentryClientInterface $sentry = null) { @@ -41,11 +67,10 @@ public function __construct(ClientInterface $client = null, SentryClientInterfac 'connect_timeout' => $connectTimeout, ]); } - + /** {@inheritdoc} */ public function getEventsForSourceLegacy(DateTimeInterface $observationTime, string $source): array { - // Build the API URL: /api/v1/events/{source}/observation/{datetime} $formattedTime = $observationTime->format('Y-m-d H:i:s'); $encodedTime = urlencode($formattedTime); @@ -120,12 +145,140 @@ public function getDistributions(string $size, int $fromTimestamp, int $toTimest } } + /** {@inheritdoc} */ + public function getEventsBatch(array $timestamps, array $sources): 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 []; + } + + $sourcesParam = implode('::', $validSources); + $chunks = array_chunk($timestamps, 150); + $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 (\Exception $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; + } + }; + + // First chunk returns full response (event_types + events + observations) + $merged = $fetchChunk($chunks[0]); + + // Subsequent chunks only add new observations (event_types and events are the same) + for ($i = 1; $i < count($chunks); $i++) { + $chunk = $fetchChunk($chunks[$i]); + $merged['observations'] += $chunk['observations']; + } + + // Convert deduplicated response to legacy format per timestamp + $result = []; + foreach ($merged['observations'] as $timestamp => $obs) { + $result[$timestamp] = $this->batchToLegacy( + $merged['event_types'], + $merged['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: [...]}]}] + */ + private function batchToLegacy(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) { + // Event not active at this timestamp + if (!isset($obs[$eventId])) continue; + + $event = $events[$eventId] ?? null; + if (!$event) continue; + + $coords = $obs[$eventId]; + + // Merge static event data with rotated coordinates + $legacyEvent = $event; + $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']; + + // Shift footprint polygon by the same rotation delta as the center point + $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; + } + /** - * Parse the HTTP response and decode JSON + * Parse the HTTP response body as JSON. + * Validates that the response is valid JSON and returns an array. * * @param \Psr\Http\Message\ResponseInterface $response - * @return array - * @throws EventsApiException if JSON decoding fails or response format is unexpected + * @return array Decoded JSON response + * @throws \RuntimeException if JSON decoding fails or response is not an array */ private function parseResponse($response): array { diff --git a/src/Event/Api/EventsApiInterface.php b/src/Event/Api/EventsApiInterface.php index dc9b15d52..b7d03d8fa 100644 --- a/src/Event/Api/EventsApiInterface.php +++ b/src/Event/Api/EventsApiInterface.php @@ -38,4 +38,15 @@ public function getEventsInRange(int $fromTimestamp, int $toTimestamp, array $pa * @throws EventsApiException on API errors or unexpected responses */ 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']) + * @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): array; } diff --git a/src/Event/EventsStateManager.php b/src/Event/EventsStateManager.php index 6aa5241ea..750ab224a 100644 --- a/src/Event/EventsStateManager.php +++ b/src/Event/EventsStateManager.php @@ -175,11 +175,23 @@ public function export() : string * Tells if there is events in this manager * @return bool */ - public function hasEvents() : bool + public function hasEvents() : bool { return count($this->events_tree) > 0; } + /** + * Get the source names (HEK, CCMC, RHESSI) that have events enabled + * @return string[] + */ + public function getSources(): array + { + return array_map( + fn($key) => str_replace('tree_', '', $key), + array_keys($this->events_state) + ); + } + /** * Lets you to access to events_state * @return array diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index faead5742..7bf3cbcb1 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -63,6 +63,8 @@ class Image_Composite_HelioviewerCompositeImage { protected $switchSources; protected $celestialBodiesLabels; protected $celestialBodiesTrajectories; + protected $eventsApi; + protected array $batchEventResponse; /** * Creates a new HelioviewerCompositeImage instance @@ -99,7 +101,8 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi 'grayscale' => false, 'eclipse' => false, 'moon' => false, - 'eventsApi' => null + 'eventsApi' => null, + 'batchEventResponse' => [] ); $options = array_replace($defaults, $options); @@ -110,6 +113,7 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi $this->db = $options['database'] ? $options['database'] : new Database_ImgIndex(); $this->eventsApi = $options['eventsApi'] ?? new EventsApi(); + $this->batchEventResponse = $options['batchEventResponse']; $this->layers = $layers; $this->eventsManager = $eventsManager; $this->movieIcons = $movieIcons; @@ -589,22 +593,24 @@ private function _addEventLayer($imagickImage) { $markerPinPixelOffsetX = 12; $markerPinPixelOffsetY = 38; - // Fetch events from all sources via EventsApi - $observationTime = new DateTimeImmutable($this->date); - $allSources = ['CCMC', 'HEK', 'RHESSI']; - $event_categories = []; - - foreach ($allSources as $source) { + // Fetch events via batch (movies have pre-fetched, screenshots fetch for single timestamp) + if (empty($this->batchEventResponse)) { try { - $sourceData = $this->eventsApi->getEventsForSourceLegacy($observationTime, $source); - $event_categories = array_merge($event_categories, $sourceData); + $this->batchEventResponse = $this->eventsApi->getEventsBatch( + [$this->date], + EventsApi::VALID_SOURCES + ); } catch (EventsApiException $e) { - // Already captured to Sentry by EventsApi + error_log("[CompositeImage] Batch events failed for {$this->date}: " . $e->getMessage()); } catch (\Exception $e) { + error_log("[CompositeImage] Unexpected error fetching events for {$this->date}: " . $e->getMessage()); Sentry::capture($e); } } + $event_categories = $this->batchEventResponse[$this->date] ?? []; + if (empty($event_categories)) return; + // Lay down all relevant event REGIONS first $events_to_render = []; $events_manager = $this->eventsManager; diff --git a/src/Movie/HelioviewerMovie.php b/src/Movie/HelioviewerMovie.php index d8fb0cdca..557f0ff82 100644 --- a/src/Movie/HelioviewerMovie.php +++ b/src/Movie/HelioviewerMovie.php @@ -36,6 +36,8 @@ require_once HV_ROOT_DIR . '/../src/Helper/Serialize.php'; use Helioviewer\Api\Event\EventsStateManager; +use Helioviewer\Api\Event\Api\EventsApi; +use Helioviewer\Api\Event\Api\EventsApiException; use Helioviewer\Api\Sentry\Sentry; /** @@ -518,6 +520,26 @@ private function _buildMovieFrames($watermark) { 'switchSources' => $this->switchSources ); + // Preload events for all frames in 1-2 batch requests + $timestamps = $this->_getTimeStamps(); + $eventsApi = new EventsApi(); + $batchResponse = []; + $sources = $this->_eventsManager->getSources(); + + if ($this->_eventsManager->hasEvents()) { + try { + $batchResponse = $eventsApi->getEventsBatch($timestamps, $sources); + } catch (EventsApiException $e) { + error_log("[Movie:{$this->publicId}] Batch events failed: " . $e->getMessage()); + } catch (\Exception $e) { + error_log("[Movie:{$this->publicId}] Unexpected error fetching events: " . $e->getMessage()); + Sentry::capture($e); + } + } + + $options['batchEventResponse'] = $batchResponse; + $options['eventsApi'] = $eventsApi; + // Index of preview frame $previewIndex = floor($this->numFrames/2); From d513fd75d3a6ef856b5cffdd256120fa574b5888 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 27 Mar 2026 21:54:28 +0000 Subject: [PATCH 26/49] tests for helper functions getSources and filterSources --- tests/unit_tests/events/EventsApiTest.php | 38 ++++++++++++++++ .../events/EventsStateManagerTest.php | 44 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/tests/unit_tests/events/EventsApiTest.php b/tests/unit_tests/events/EventsApiTest.php index 9fa3c7941..662163ff6 100644 --- a/tests/unit_tests/events/EventsApiTest.php +++ b/tests/unit_tests/events/EventsApiTest.php @@ -212,4 +212,42 @@ public function testGetDistributionsThrowsAndCapturesOnError(): void $this->eventsApi->getDistributions('h', 1000, 2000, ['CCMC>>DONKI>>CME']); } + + 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/EventsStateManagerTest.php b/tests/unit_tests/events/EventsStateManagerTest.php index 960abf59e..966be8f61 100644 --- a/tests/unit_tests/events/EventsStateManagerTest.php +++ b/tests/unit_tests/events/EventsStateManagerTest.php @@ -454,5 +454,49 @@ public function testItShouldCorrectlyReportNonExistantEventTypesLabelVisible() $manager = EventsStateManager::buildFromEventsState($this->eventsState); $this->assertFalse($manager->isEventTypeLabelVisible('unknown_event_type')); } + + public function testItShouldReturnSourceNamesWithoutTreePrefix() + { + $manager = EventsStateManager::buildFromEventsState($this->eventsState); + $sources = $manager->getSources(); + $this->assertEquals(['HEK', 'CCMC'], $sources); + } + + public function testItShouldReturnAllThreeSourcesWhenPresent() + { + $state = $this->eventsState; + $state['tree_RHESSI'] = [ + 'id' => 'RHESSI', + 'markers_visible' => true, + 'labels_visible' => true, + 'layers' => [ + [ + 'event_type' => 'xray', + 'frms' => ['all'], + 'event_instances' => [], + 'open' => true, + ] + ] + ]; + $manager = EventsStateManager::buildFromEventsState($state); + $sources = $manager->getSources(); + $this->assertEquals(['HEK', 'CCMC', 'RHESSI'], $sources); + } + + public function testItShouldReturnSingleSourceWhenOnlyOnePresent() + { + $state = [ + 'tree_CCMC' => $this->eventsState['tree_CCMC'] + ]; + $manager = EventsStateManager::buildFromEventsState($state); + $this->assertEquals(['CCMC'], $manager->getSources()); + } + + public function testItShouldReturnEmptySourcesWhenStateIsEmpty() + { + $state = []; + $manager = EventsStateManager::buildFromEventsState($state); + $this->assertEquals([], $manager->getSources()); + } } From 7c2b3a4931d870f3c6a2e59f5bf3d817aa8e4e2c Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 27 Mar 2026 22:16:33 +0000 Subject: [PATCH 27/49] distribute tests to multiple files --- tests/unit_tests/events/EventsApiTest.php | 253 ------------------ tests/unit_tests/events/api/EventsApiTest.php | 73 +++++ .../events/api/GetDistributionsTest.php | 58 ++++ .../api/GetEventsForSourceLegacyTest.php | 136 ++++++++++ .../events/api/GetEventsInRangeTest.php | 58 ++++ 5 files changed, 325 insertions(+), 253 deletions(-) delete mode 100644 tests/unit_tests/events/EventsApiTest.php create mode 100644 tests/unit_tests/events/api/EventsApiTest.php create mode 100644 tests/unit_tests/events/api/GetDistributionsTest.php create mode 100644 tests/unit_tests/events/api/GetEventsForSourceLegacyTest.php create mode 100644 tests/unit_tests/events/api/GetEventsInRangeTest.php diff --git a/tests/unit_tests/events/EventsApiTest.php b/tests/unit_tests/events/EventsApiTest.php deleted file mode 100644 index 662163ff6..000000000 --- a/tests/unit_tests/events/EventsApiTest.php +++ /dev/null @@ -1,253 +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\Sentry\ClientInterface as SentryClientInterface; - -final class EventsApiTest extends TestCase -{ - private $mockClient; - private $mockSentry; - private $eventsApi; - - protected function setUp(): void - { - $this->mockClient = $this->createMock(ClientInterface::class); - $this->mockSentry = $this->createMock(SentryClientInterface::class); - $this->eventsApi = new EventsApi($this->mockClient, $this->mockSentry); - } - - public function testConstructorSetsDefaultSentryContext(): void - { - $this->mockSentry->expects($this->once()) - ->method('setContext') - ->with('EventsApi', $this->callback(function ($params) { - return array_key_exists('api_url', $params) - && array_key_exists('timeout', $params) - && array_key_exists('connect_timeout', $params); - })); - - new EventsApi($this->mockClient, $this->mockSentry); - } - - public function testGetEventsForSourceLegacySuccess(): void - { - $responseData = [ - ['id' => 1, 'type' => 'CME'], - ['id' => 2, 'type' => 'Flare'] - ]; - - $this->mockClient->expects($this->once()) - ->method('request') - ->with('GET', $this->stringContains('/helioviewer/events/CCMC/observation/')) - ->willReturn(new Response(200, [], json_encode($responseData))); - - $result = $this->eventsApi->getEventsForSourceLegacy( - new DateTimeImmutable('2024-01-15 12:00:00'), - 'CCMC' - ); - - $this->assertEquals($responseData, $result); - } - - public function testGetEventsForSourceLegacySetsEndpointContext(): void - { - $this->mockClient->method('request') - ->willReturn(new Response(200, [], json_encode([]))); - - // First call is from constructor, second from the method - $this->mockSentry->expects($this->exactly(2)) - ->method('setContext') - ->withConsecutive( - ['EventsApi', $this->anything()], - ['EventsApi', $this->callback(function ($params) { - return array_key_exists('endpoint', $params) - && str_contains($params['endpoint'], '/helioviewer/events/CCMC/observation/'); - })] - ); - - $eventsApi = new EventsApi($this->mockClient, $this->mockSentry); - $eventsApi->getEventsForSourceLegacy( - new DateTimeImmutable('2024-01-15 12:00:00'), - 'CCMC' - ); - } - - public function testGetEventsForSourceLegacyUrlEncodesObservationTime(): void - { - $this->mockClient->expects($this->once()) - ->method('request') - ->with('GET', $this->stringContains('2024-01-15+12%3A30%3A45')) - ->willReturn(new Response(200, [], json_encode([]))); - - $this->eventsApi->getEventsForSourceLegacy( - new DateTimeImmutable('2024-01-15 12:30:45'), - 'CCMC' - ); - } - - public function testGetEventsForSourceLegacyThrowsAndCapturesOnError(): void - { - $this->mockClient->method('request') - ->willThrowException(new \RuntimeException('connection failed')); - - // Constructor setContext + method setContext + error setContext = 3, then capture - $this->mockSentry->expects($this->atLeastOnce())->method('setContext'); - $this->mockSentry->expects($this->once()) - ->method('capture') - ->with($this->isInstanceOf(EventsApiException::class)); - - $this->expectException(EventsApiException::class); - $this->expectExceptionMessage('Failed to fetch events for source: connection failed'); - - $this->eventsApi->getEventsForSourceLegacy( - new DateTimeImmutable('2024-01-15 12:00:00'), - 'CCMC' - ); - } - - public function testGetEventsForSourceLegacyThrowsOnInvalidJson(): void - { - $this->mockClient->method('request') - ->willReturn(new Response(200, [], 'invalid json {')); - - $this->mockSentry->expects($this->once()) - ->method('capture') - ->with($this->isInstanceOf(EventsApiException::class)); - - $this->expectException(EventsApiException::class); - $this->expectExceptionMessage('Failed to decode JSON response'); - - $this->eventsApi->getEventsForSourceLegacy( - new DateTimeImmutable('2024-01-15 12:00:00'), - 'CCMC' - ); - } - - public function testGetEventsForSourceLegacyThrowsWhenResponseIsNotArray(): void - { - $this->mockClient->method('request') - ->willReturn(new Response(200, [], '"just a string"')); - - $this->mockSentry->expects($this->once()) - ->method('capture') - ->with($this->isInstanceOf(EventsApiException::class)); - - $this->expectException(EventsApiException::class); - $this->expectExceptionMessage('Unexpected response format: expected array, got string'); - - $this->eventsApi->getEventsForSourceLegacy( - new DateTimeImmutable('2024-01-15 12:00:00'), - 'CCMC' - ); - } - - public function testGetEventsInRangeSuccess(): void - { - $responseData = [['id' => 1, 'type' => 'CME']]; - $paths = ['CCMC>>DONKI>>CME', 'HEK>>Active Region']; - - $this->mockClient->expects($this->once()) - ->method('request') - ->with('POST', '/helioviewer/events/from/1000/to/2000', [ - 'json' => ['paths' => $paths] - ]) - ->willReturn(new Response(200, [], json_encode($responseData))); - - $result = $this->eventsApi->getEventsInRange(1000, 2000, $paths); - - $this->assertEquals($responseData, $result); - } - - public function testGetEventsInRangeThrowsAndCapturesOnError(): void - { - $this->mockClient->method('request') - ->willThrowException(new \RuntimeException('timeout')); - - $this->mockSentry->expects($this->once()) - ->method('capture') - ->with($this->isInstanceOf(EventsApiException::class)); - - $this->expectException(EventsApiException::class); - $this->expectExceptionMessage('Failed to fetch events: timeout'); - - $this->eventsApi->getEventsInRange(1000, 2000, ['CCMC>>DONKI>>CME']); - } - - public function testGetDistributionsSuccess(): void - { - $responseData = [['bucket' => '2024-01-15', 'count' => 5]]; - $paths = ['CCMC>>DONKI>>CME']; - - $this->mockClient->expects($this->once()) - ->method('request') - ->with('POST', '/helioviewer/distributions/size/h/from/1000/to/2000', [ - 'json' => ['paths' => $paths] - ]) - ->willReturn(new Response(200, [], json_encode($responseData))); - - $result = $this->eventsApi->getDistributions('h', 1000, 2000, $paths); - - $this->assertEquals($responseData, $result); - } - - public function testGetDistributionsThrowsAndCapturesOnError(): void - { - $this->mockClient->method('request') - ->willThrowException(new \RuntimeException('server error')); - - $this->mockSentry->expects($this->once()) - ->method('capture') - ->with($this->isInstanceOf(EventsApiException::class)); - - $this->expectException(EventsApiException::class); - $this->expectExceptionMessage('Failed to fetch distributions: server error'); - - $this->eventsApi->getDistributions('h', 1000, 2000, ['CCMC>>DONKI>>CME']); - } - - 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/EventsApiTest.php b/tests/unit_tests/events/api/EventsApiTest.php new file mode 100644 index 000000000..edb031427 --- /dev/null +++ b/tests/unit_tests/events/api/EventsApiTest.php @@ -0,0 +1,73 @@ + + */ + +use PHPUnit\Framework\TestCase; +use GuzzleHttp\ClientInterface; +use Helioviewer\Api\Event\Api\EventsApi; +use Helioviewer\Api\Sentry\ClientInterface as SentryClientInterface; + +final class EventsApiTest extends TestCase +{ + private $mockClient; + private $mockSentry; + + protected function setUp(): void + { + $this->mockClient = $this->createMock(ClientInterface::class); + $this->mockSentry = $this->createMock(SentryClientInterface::class); + } + + public function testConstructorSetsDefaultSentryContext(): void + { + $this->mockSentry->expects($this->once()) + ->method('setContext') + ->with('EventsApi', $this->callback(function ($params) { + return array_key_exists('api_url', $params) + && array_key_exists('timeout', $params) + && array_key_exists('connect_timeout', $params); + })); + + new EventsApi($this->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/GetDistributionsTest.php b/tests/unit_tests/events/api/GetDistributionsTest.php new file mode 100644 index 000000000..51fa38f6f --- /dev/null +++ b/tests/unit_tests/events/api/GetDistributionsTest.php @@ -0,0 +1,58 @@ + + */ + +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\Sentry\ClientInterface as SentryClientInterface; + +final class GetDistributionsTest extends TestCase +{ + private $mockClient; + private $mockSentry; + private $eventsApi; + + protected function setUp(): void + { + $this->mockClient = $this->createMock(ClientInterface::class); + $this->mockSentry = $this->createMock(SentryClientInterface::class); + $this->eventsApi = new EventsApi($this->mockClient, $this->mockSentry); + } + + public function testItShouldReturnDistributionsOnSuccess(): void + { + $responseData = [['bucket' => '2024-01-15', 'count' => 5]]; + $paths = ['CCMC>>DONKI>>CME']; + + $this->mockClient->expects($this->once()) + ->method('request') + ->with('POST', '/helioviewer/distributions/size/h/from/1000/to/2000', [ + 'json' => ['paths' => $paths] + ]) + ->willReturn(new Response(200, [], json_encode($responseData))); + + $result = $this->eventsApi->getDistributions('h', 1000, 2000, $paths); + + $this->assertEquals($responseData, $result); + } + + public function testItShouldThrowAndCaptureOnError(): void + { + $this->mockClient->method('request') + ->willThrowException(new \RuntimeException('server error')); + + $this->mockSentry->expects($this->once()) + ->method('capture') + ->with($this->isInstanceOf(EventsApiException::class)); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Failed to fetch distributions: server error'); + + $this->eventsApi->getDistributions('h', 1000, 2000, ['CCMC>>DONKI>>CME']); + } +} diff --git a/tests/unit_tests/events/api/GetEventsForSourceLegacyTest.php b/tests/unit_tests/events/api/GetEventsForSourceLegacyTest.php new file mode 100644 index 000000000..64a1a4dde --- /dev/null +++ b/tests/unit_tests/events/api/GetEventsForSourceLegacyTest.php @@ -0,0 +1,136 @@ + + */ + +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\Sentry\ClientInterface as SentryClientInterface; + +final class GetEventsForSourceLegacyTest extends TestCase +{ + private $mockClient; + private $mockSentry; + private $eventsApi; + + protected function setUp(): void + { + $this->mockClient = $this->createMock(ClientInterface::class); + $this->mockSentry = $this->createMock(SentryClientInterface::class); + $this->eventsApi = new EventsApi($this->mockClient, $this->mockSentry); + } + + public function testItShouldReturnEventsOnSuccess(): void + { + $responseData = [ + ['id' => 1, 'type' => 'CME'], + ['id' => 2, 'type' => 'Flare'] + ]; + + $this->mockClient->expects($this->once()) + ->method('request') + ->with('GET', $this->stringContains('/helioviewer/events/CCMC/observation/')) + ->willReturn(new Response(200, [], json_encode($responseData))); + + $result = $this->eventsApi->getEventsForSourceLegacy( + new DateTimeImmutable('2024-01-15 12:00:00'), + 'CCMC' + ); + + $this->assertEquals($responseData, $result); + } + + public function testItShouldSetEndpointContext(): void + { + $this->mockClient->method('request') + ->willReturn(new Response(200, [], json_encode([]))); + + $this->mockSentry->expects($this->exactly(2)) + ->method('setContext') + ->withConsecutive( + ['EventsApi', $this->anything()], + ['EventsApi', $this->callback(function ($params) { + return array_key_exists('endpoint', $params) + && str_contains($params['endpoint'], '/helioviewer/events/CCMC/observation/'); + })] + ); + + $eventsApi = new EventsApi($this->mockClient, $this->mockSentry); + $eventsApi->getEventsForSourceLegacy( + new DateTimeImmutable('2024-01-15 12:00:00'), + 'CCMC' + ); + } + + public function testItShouldUrlEncodeObservationTime(): void + { + $this->mockClient->expects($this->once()) + ->method('request') + ->with('GET', $this->stringContains('2024-01-15+12%3A30%3A45')) + ->willReturn(new Response(200, [], json_encode([]))); + + $this->eventsApi->getEventsForSourceLegacy( + new DateTimeImmutable('2024-01-15 12:30:45'), + 'CCMC' + ); + } + + public function testItShouldThrowAndCaptureOnError(): void + { + $this->mockClient->method('request') + ->willThrowException(new \RuntimeException('connection failed')); + + $this->mockSentry->expects($this->atLeastOnce())->method('setContext'); + $this->mockSentry->expects($this->once()) + ->method('capture') + ->with($this->isInstanceOf(EventsApiException::class)); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Failed to fetch events for source: connection failed'); + + $this->eventsApi->getEventsForSourceLegacy( + new DateTimeImmutable('2024-01-15 12:00:00'), + 'CCMC' + ); + } + + public function testItShouldThrowOnInvalidJson(): void + { + $this->mockClient->method('request') + ->willReturn(new Response(200, [], 'invalid json {')); + + $this->mockSentry->expects($this->once()) + ->method('capture') + ->with($this->isInstanceOf(EventsApiException::class)); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Failed to decode JSON response'); + + $this->eventsApi->getEventsForSourceLegacy( + new DateTimeImmutable('2024-01-15 12:00:00'), + 'CCMC' + ); + } + + public function testItShouldThrowWhenResponseIsNotArray(): void + { + $this->mockClient->method('request') + ->willReturn(new Response(200, [], '"just a string"')); + + $this->mockSentry->expects($this->once()) + ->method('capture') + ->with($this->isInstanceOf(EventsApiException::class)); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Unexpected response format: expected array, got string'); + + $this->eventsApi->getEventsForSourceLegacy( + new DateTimeImmutable('2024-01-15 12:00:00'), + 'CCMC' + ); + } +} diff --git a/tests/unit_tests/events/api/GetEventsInRangeTest.php b/tests/unit_tests/events/api/GetEventsInRangeTest.php new file mode 100644 index 000000000..96f674dd2 --- /dev/null +++ b/tests/unit_tests/events/api/GetEventsInRangeTest.php @@ -0,0 +1,58 @@ + + */ + +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\Sentry\ClientInterface as SentryClientInterface; + +final class GetEventsInRangeTest extends TestCase +{ + private $mockClient; + private $mockSentry; + private $eventsApi; + + protected function setUp(): void + { + $this->mockClient = $this->createMock(ClientInterface::class); + $this->mockSentry = $this->createMock(SentryClientInterface::class); + $this->eventsApi = new EventsApi($this->mockClient, $this->mockSentry); + } + + public function testItShouldReturnEventsOnSuccess(): void + { + $responseData = [['id' => 1, 'type' => 'CME']]; + $paths = ['CCMC>>DONKI>>CME', 'HEK>>Active Region']; + + $this->mockClient->expects($this->once()) + ->method('request') + ->with('POST', '/helioviewer/events/from/1000/to/2000', [ + 'json' => ['paths' => $paths] + ]) + ->willReturn(new Response(200, [], json_encode($responseData))); + + $result = $this->eventsApi->getEventsInRange(1000, 2000, $paths); + + $this->assertEquals($responseData, $result); + } + + public function testItShouldThrowAndCaptureOnError(): void + { + $this->mockClient->method('request') + ->willThrowException(new \RuntimeException('timeout')); + + $this->mockSentry->expects($this->once()) + ->method('capture') + ->with($this->isInstanceOf(EventsApiException::class)); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Failed to fetch events: timeout'); + + $this->eventsApi->getEventsInRange(1000, 2000, ['CCMC>>DONKI>>CME']); + } +} From c90507fa9d411bb00486ac34c2149f8078e90119 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 27 Mar 2026 23:26:04 +0000 Subject: [PATCH 28/49] create seperate object for legacy event response, to be able to test the code --- src/Event/Api/EventsApi.php | 83 ++------------------- src/Event/Api/LegacyEvents.php | 95 +++++++++++++++++++++++++ src/Event/Api/LegacyEventsInterface.php | 27 +++++++ 3 files changed, 127 insertions(+), 78 deletions(-) create mode 100644 src/Event/Api/LegacyEvents.php create mode 100644 src/Event/Api/LegacyEventsInterface.php diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index 668b610c8..85ea83ae7 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -25,6 +25,7 @@ class EventsApi implements EventsApiInterface { private ClientInterface $client; private SentryClientInterface $sentry; + private LegacyEventsInterface $legacyEvents; /** * Filter an array of source names to only valid ones. @@ -44,8 +45,9 @@ 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) + public function __construct(ClientInterface $client = null, SentryClientInterface $sentry = null, LegacyEventsInterface $legacyEvents = null) { $timeout = defined('HV_EVENTS_API_TIMEOUT') ? HV_EVENTS_API_TIMEOUT : 10; $connectTimeout = 2; @@ -60,6 +62,7 @@ public function __construct(ClientInterface $client = null, SentryClientInterfac ] ]); $this->sentry = $sentry ?? Sentry::$client; + $this->legacyEvents = $legacyEvents ?? new LegacyEvents(); $this->sentry->setContext('EventsApi', [ 'api_url' => $baseUrl, @@ -193,83 +196,7 @@ public function getEventsBatch(array $timestamps, array $sources): array } // Convert deduplicated response to legacy format per timestamp - $result = []; - foreach ($merged['observations'] as $timestamp => $obs) { - $result[$timestamp] = $this->batchToLegacy( - $merged['event_types'], - $merged['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: [...]}]}] - */ - private function batchToLegacy(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) { - // Event not active at this timestamp - if (!isset($obs[$eventId])) continue; - - $event = $events[$eventId] ?? null; - if (!$event) continue; - - $coords = $obs[$eventId]; - - // Merge static event data with rotated coordinates - $legacyEvent = $event; - $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']; - - // Shift footprint polygon by the same rotation delta as the center point - $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; + return $this->legacyEvents->convertAll($merged); } /** diff --git a/src/Event/Api/LegacyEvents.php b/src/Event/Api/LegacyEvents.php new file mode 100644 index 000000000..b06113e07 --- /dev/null +++ b/src/Event/Api/LegacyEvents.php @@ -0,0 +1,95 @@ + $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['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 new file mode 100644 index 000000000..278cbea61 --- /dev/null +++ b/src/Event/Api/LegacyEventsInterface.php @@ -0,0 +1,27 @@ + Date: Mon, 30 Mar 2026 16:43:34 +0000 Subject: [PATCH 29/49] test batch event query endpoints in api client --- .../events/api/GetEventsBatchTest.php | 161 +++++++++++ .../events/api/LegacyEventsTest.php | 268 ++++++++++++++++++ 2 files changed, 429 insertions(+) create mode 100644 tests/unit_tests/events/api/GetEventsBatchTest.php create mode 100644 tests/unit_tests/events/api/LegacyEventsTest.php diff --git a/tests/unit_tests/events/api/GetEventsBatchTest.php b/tests/unit_tests/events/api/GetEventsBatchTest.php new file mode 100644 index 000000000..c28ef9a2e --- /dev/null +++ b/tests/unit_tests/events/api/GetEventsBatchTest.php @@ -0,0 +1,161 @@ + + */ + +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']); + } + + 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']); + } +} diff --git a/tests/unit_tests/events/api/LegacyEventsTest.php b/tests/unit_tests/events/api/LegacyEventsTest.php new file mode 100644 index 000000000..38843136f --- /dev/null +++ b/tests/unit_tests/events/api/LegacyEventsTest.php @@ -0,0 +1,268 @@ + + */ + +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 432c16eeef2f16ad2d6a7f5593b0e23d679b3542 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 30 Mar 2026 17:26:56 +0000 Subject: [PATCH 30/49] minor bugs for events strings doesn't start end with [] --- src/Event/EventSelections.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Event/EventSelections.php b/src/Event/EventSelections.php index b5b4ac331..1c74ea5b3 100644 --- a/src/Event/EventSelections.php +++ b/src/Event/EventSelections.php @@ -88,7 +88,12 @@ public static function buildFromLegacyEventStrings(string $events_state_string): $events_state_string = trim($events_state_string); if (!empty($events_state_string)) { - $event_strings = explode("],[", trim(stripslashes($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) { From 0b3841d4263bb4b14383fe2af5633ce9773e4e2c Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 30 Mar 2026 17:29:47 +0000 Subject: [PATCH 31/49] events selections tests for different cases --- .../unit_tests/events/EventSelectionsTest.php | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/unit_tests/events/EventSelectionsTest.php diff --git a/tests/unit_tests/events/EventSelectionsTest.php b/tests/unit_tests/events/EventSelectionsTest.php new file mode 100644 index 000000000..2d59e28d8 --- /dev/null +++ b/tests/unit_tests/events/EventSelectionsTest.php @@ -0,0 +1,116 @@ + + */ + +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]); + } +} From a1478ce48ddc92fc77f661417e422d9656dda45c Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 31 Mar 2026 15:36:14 +0000 Subject: [PATCH 32/49] testibility improvements --- src/Event/EventsTimeline.php | 15 +- .../unit_tests/events/EventsTimelineTest.php | 147 ++++++++++++++++++ 2 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 tests/unit_tests/events/EventsTimelineTest.php diff --git a/src/Event/EventsTimeline.php b/src/Event/EventsTimeline.php index 50bc12f4b..8b6c88e86 100644 --- a/src/Event/EventsTimeline.php +++ b/src/Event/EventsTimeline.php @@ -18,6 +18,7 @@ use InvalidArgumentException; use Exception; use Helioviewer\Api\Event\Api\EventsApi; +use Helioviewer\Api\Event\Api\EventsApiInterface; require_once HV_ROOT_DIR . '/../src/Helper/HelioviewerEvents.php'; @@ -35,7 +36,7 @@ class EventsTimeline private \Helper_HelioviewerEvents $events; private EventSelections $eventSelections; - private EventsApi $eventsApi; + private EventsApiInterface $eventsApi; private int $startMs; // milliseconds private int $endMs; // milliseconds private int $currentMs; // milliseconds @@ -44,7 +45,7 @@ class EventsTimeline /** * @throws InvalidArgumentException If parameters are invalid */ - public function __construct(string $eventLayers, $startTimestamp, $endTimestamp, $currentTimestamp, ?EventsApi $eventsApi = null) + public function __construct(string $eventLayers, $startTimestamp, $endTimestamp, $currentTimestamp, ?EventsApiInterface $eventsApi = null) { $this->eventsApi = $eventsApi ?? new EventsApi(); // Validate all three timestamps are provided and positive integers (in milliseconds) @@ -70,6 +71,12 @@ public function __construct(string $eventLayers, $startTimestamp, $endTimestamp, $this->eventSelections = EventSelections::buildFromLegacyEventStrings($eventLayers); } + public function getResolution(): string { return $this->resolution; } + public function getStartMs(): int { return $this->startMs; } + public function getEndMs(): int { return $this->endMs; } + public function getCurrentMs(): int { return $this->currentMs; } + public function getEventSelections(): EventSelections { return $this->eventSelections; } + public function timeline(): string { if ($this->resolution === 'm') { @@ -81,7 +88,7 @@ public function timeline(): string /** * Get aggregated event coverage using EventsApi distributions endpoint */ - private function getAggregatedCoverage(): string + public function getAggregatedCoverage(): string { // Original request range (in milliseconds) $startMs = $this->startMs; @@ -144,7 +151,7 @@ private function getAggregatedCoverage(): string /** * Get minute-level event coverage using EventsApi events endpoint */ - private function getMinuteCoverage(): string + public function getMinuteCoverage(): string { // Calculate extended time range (3x visible range for smooth scrolling) $distance = $this->endMs - $this->startMs; diff --git a/tests/unit_tests/events/EventsTimelineTest.php b/tests/unit_tests/events/EventsTimelineTest.php new file mode 100644 index 000000000..ae8da3c09 --- /dev/null +++ b/tests/unit_tests/events/EventsTimelineTest.php @@ -0,0 +1,147 @@ + + */ + +use PHPUnit\Framework\TestCase; +use Helioviewer\Api\Event\EventsTimeline; +use Helioviewer\Api\Event\Api\EventsApiInterface; + +final class EventsTimelineTest extends TestCase +{ + private $mockEventsApi; + + protected function setUp(): void + { + $this->mockEventsApi = $this->createMock(EventsApiInterface::class); + } + + public static function invalidTimestampProvider(): array + { + return [ + 'null_start' => [null, 2000, 1500], + 'null_end' => [1000, null, 1500], + 'null_current' => [1000, 2000, null], + 'zero_start' => [0, 2000, 1500], + 'zero_end' => [1000, 0, 1500], + 'zero_current' => [1000, 2000, 0], + 'negative_start' => [-1, 2000, 1500], + 'string_start' => ['abc', 2000, 1500], + 'string_end' => [1000, 'abc', 1500], + 'string_current' => [1000, 2000, 'abc'], + ]; + } + + /** + * @dataProvider invalidTimestampProvider + */ + public function testItShouldThrowForInvalidTimestamps($start, $end, $current): void + { + $this->expectException(InvalidArgumentException::class); + new EventsTimeline('[AR,all,1]', $start, $end, $current, $this->mockEventsApi); + } + + public function testItShouldThrowWhenStartIsGreaterThanEnd(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('startTimestamp must be less than or equal to endTimestamp'); + new EventsTimeline('[AR,all,1]', 2000, 1000, 1500, $this->mockEventsApi); + } + + public function testItShouldNotThrowWhenStartEqualsEnd(): void + { + $this->expectNotToPerformAssertions(); + new EventsTimeline('[AR,all,1]', 1000, 1000, 1000, $this->mockEventsApi); + } + + public function testItShouldNotThrowForValidTimestamps(): void + { + $this->expectNotToPerformAssertions(); + new EventsTimeline('[AR,all,1]', 1000, 2000, 1500, $this->mockEventsApi); + } + + public function testItShouldStoreTimestamps(): void + { + $timeline = new EventsTimeline('[AR,all,1]', 1000, 2000, 1500, $this->mockEventsApi); + $this->assertEquals(1000, $timeline->getStartMs()); + $this->assertEquals(2000, $timeline->getEndMs()); + $this->assertEquals(1500, $timeline->getCurrentMs()); + } + + public function testItShouldParseEventSelections(): void + { + $timeline = new EventsTimeline('[AR,all,1],[C3,all,1]', 1000, 2000, 1500, $this->mockEventsApi); + $selections = $timeline->getEventSelections(); + $this->assertCount(2, $selections); + $this->assertEquals('HEK>>Active Region', $selections[0]); + $this->assertEquals('CCMC>>DONKI', $selections[1]); + } + + public function testItShouldParseEmptyEventSelections(): void + { + $timeline = new EventsTimeline('', 1000, 2000, 1500, $this->mockEventsApi); + $this->assertCount(0, $timeline->getEventSelections()); + } + + public static function resolutionProvider(): array + { + return [ + 'under_1_day_minute' => [ + 1000, + 1000 + 86400000 - 1, + 'm', + ], + 'exactly_1_day_30m' => [ + 1000, + 1000 + 86400000, + '30m', + ], + 'under_2_days_30m' => [ + 1000, + 1000 + 172800000 - 1, + '30m', + ], + 'under_10_days_hourly' => [ + 1000, + 1000 + 864000000 - 1, + 'h', + ], + 'under_6_months_daily' => [ + 1000, + 1000 + 16070400000 - 1, + 'D', + ], + 'under_15_months_weekly' => [ + 1000, + 1000 + 40176000000 - 1, + 'W', + ], + 'under_5_years_monthly' => [ + 1000, + 1000 + 157680000000 - 1, + 'M', + ], + 'over_5_years_yearly' => [ + 1000, + 1000 + 157680000000, + 'Y', + ], + 'zero_range_minute' => [ + 1000, + 1000, + 'm', + ], + ]; + } + + /** + * @dataProvider resolutionProvider + */ + public function testItShouldCalculateCorrectResolution(int $start, int $end, string $expected): void + { + $current = $start; + $timeline = new EventsTimeline('[AR,all,1]', $start, $end, $current, $this->mockEventsApi); + $this->assertEquals($expected, $timeline->getResolution()); + } +} From 1912aa82c0adc3880f3a0f9134243cc7e7f4bbcf Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 6 Apr 2026 16:16:42 +0000 Subject: [PATCH 33/49] fix bug prevent individual events to be processed in movies --- src/Event/Api/LegacyEvents.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Event/Api/LegacyEvents.php b/src/Event/Api/LegacyEvents.php index b06113e07..6752c6700 100644 --- a/src/Event/Api/LegacyEvents.php +++ b/src/Event/Api/LegacyEvents.php @@ -59,6 +59,7 @@ public function convert(array $eventTypes, array $events, array $obs): array $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']; From d75445f649ef45a1444cb343ea40dc008a76bf4c Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 7 Apr 2026 03:14:31 +0000 Subject: [PATCH 34/49] create missing event pins --- docroot/resources/images/eventMarkers/BU.png | Bin 0 -> 544 bytes docroot/resources/images/eventMarkers/BU@2x.png | Bin 0 -> 1055 bytes docroot/resources/images/eventMarkers/C3.png | Bin 0 -> 630 bytes docroot/resources/images/eventMarkers/C3@2x.png | Bin 0 -> 1347 bytes docroot/resources/images/eventMarkers/EE.png | Bin 0 -> 366 bytes docroot/resources/images/eventMarkers/EE@2x.png | Bin 0 -> 608 bytes docroot/resources/images/eventMarkers/EP.png | Bin 0 -> 441 bytes docroot/resources/images/eventMarkers/EP@2x.png | Bin 0 -> 781 bytes docroot/resources/images/eventMarkers/F2.png | Bin 0 -> 504 bytes docroot/resources/images/eventMarkers/F2@2x.png | Bin 0 -> 946 bytes docroot/resources/images/eventMarkers/HY.png | Bin 0 -> 423 bytes docroot/resources/images/eventMarkers/HY@2x.png | Bin 0 -> 786 bytes docroot/resources/images/eventMarkers/IC.png | Bin 0 -> 508 bytes docroot/resources/images/eventMarkers/IC@2x.png | Bin 0 -> 1055 bytes docroot/resources/images/eventMarkers/NR.png | Bin 0 -> 518 bytes docroot/resources/images/eventMarkers/NR@2x.png | Bin 0 -> 1036 bytes docroot/resources/images/eventMarkers/OT.png | Bin 0 -> 525 bytes docroot/resources/images/eventMarkers/OT@2x.png | Bin 0 -> 1046 bytes docroot/resources/images/eventMarkers/PB.png | Bin 0 -> 509 bytes docroot/resources/images/eventMarkers/PB@2x.png | Bin 0 -> 1020 bytes docroot/resources/images/eventMarkers/PT.png | Bin 0 -> 416 bytes docroot/resources/images/eventMarkers/PT@2x.png | Bin 0 -> 740 bytes docroot/resources/images/eventMarkers/SR.png | Bin 0 -> 643 bytes docroot/resources/images/eventMarkers/SR@2x.png | Bin 0 -> 1348 bytes 24 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docroot/resources/images/eventMarkers/BU.png create mode 100644 docroot/resources/images/eventMarkers/BU@2x.png create mode 100644 docroot/resources/images/eventMarkers/C3.png create mode 100644 docroot/resources/images/eventMarkers/C3@2x.png create mode 100644 docroot/resources/images/eventMarkers/EE.png create mode 100644 docroot/resources/images/eventMarkers/EE@2x.png create mode 100644 docroot/resources/images/eventMarkers/EP.png create mode 100644 docroot/resources/images/eventMarkers/EP@2x.png create mode 100644 docroot/resources/images/eventMarkers/F2.png create mode 100644 docroot/resources/images/eventMarkers/F2@2x.png create mode 100644 docroot/resources/images/eventMarkers/HY.png create mode 100644 docroot/resources/images/eventMarkers/HY@2x.png create mode 100644 docroot/resources/images/eventMarkers/IC.png create mode 100644 docroot/resources/images/eventMarkers/IC@2x.png create mode 100644 docroot/resources/images/eventMarkers/NR.png create mode 100644 docroot/resources/images/eventMarkers/NR@2x.png create mode 100644 docroot/resources/images/eventMarkers/OT.png create mode 100644 docroot/resources/images/eventMarkers/OT@2x.png create mode 100644 docroot/resources/images/eventMarkers/PB.png create mode 100644 docroot/resources/images/eventMarkers/PB@2x.png create mode 100644 docroot/resources/images/eventMarkers/PT.png create mode 100644 docroot/resources/images/eventMarkers/PT@2x.png create mode 100644 docroot/resources/images/eventMarkers/SR.png create mode 100644 docroot/resources/images/eventMarkers/SR@2x.png diff --git a/docroot/resources/images/eventMarkers/BU.png b/docroot/resources/images/eventMarkers/BU.png new file mode 100644 index 0000000000000000000000000000000000000000..df8a2264d884bb5bfb54c22272022831ace09b57 GIT binary patch literal 544 zcmV+*0^j|KP)+KPoBgS8J4d;$wWu(1;K0kpKT5-qg| z3Q3`0BPxoz92)ks1l0@=R%3uwMjci zN(I932-pwh(xQco$U_(&2URK1@KvxZ--YWoFRlRj;jt>}F6w@W*|a}3O#a_8O^vkH z*F~?Zvqa3Mt%(5wKo2W3M9d~)HVwA566@_EVs>C|LR7J`R77XG8Hd$7%pIL#q0o-` zLRL$)XA92`!}s0Ml2iivIYQ<6y<_irvAZ1uZLLahe?%JUMrEl8Vm5A;CvkcIg!PjP z06;on0svkd$IE?sb6_Q-Iz71PNSmMDJiNUF0LHQwOvAwTWfjkkgM+JETt7ZzI+uxX zVr@w!OpInNY>e~(0D7%7c8fzOotIHMDbOvC-)f+<3zaw0D z?Isgnr_ucrxp($7*JKuvqJ=zP1Ft^T-hF(Y7sn;C{nMos_)w8 z+||n9s`d!;l$3`pP;K;&9Dh;7HCAe!wsM`luL7_8TKJ0Ru|-u?@&1v;&i&Qh;zn4d i(u)6t^8XFj-tQkv4UJ8u>Gc@^0000bYonR7^87z;ve8j z4I4}}x*#^1kO)$UG2tZ&k;=PRXklDH%e3>j_s-mzDbTZOrgy&YH)qbhz0(%hp|WwP zAz0B{&&gVWxiDxsi5GCPm{?c_6M+SvSH0Pbw={jIm}tROp-I4!rsNACa?yY*LbC!( znyX)cDjNx07FsW`1VZ@&6!|h#>X`yfsHu3jmB3Adrl>8;qUJblVQymSAqOtGLNlNV zy(CP@;FdxYdM2<@MFEqQYi+m=ytOFtxXVoUI;!fqPH$%xUB`}%MJnnrdC$*=-U`qKp7SR(CK)l&4vbN zJ|3Jp5>8tvd2BHLOk@;;%upt8BB|~eE<#3h43UWj=J!Q6z_lkMMrM{ zJ(EFvnOQ)f?Hmq?vYuhv{QdZ0ei#5SyBfi(u|El8 z4%oN-nFz_R^CL?u@z=$<*~Grwq9TZ#42IVd-t)Nj$6t3(BwiE%49zcNa4TY!`MIbn za1i@Rz$u@^;|fLB(K8vuot|DB)O5VIH2hG@`wC5aIO=boz(U=^vJy+tE;o&c-S# z4rcIV7vFx}TwdL@*3h^{Bj*nBb_!$aA08r|%qq)S1+w~Q= zSY9N1uAHcLhgQn4-Jw-d1ZSP3Mo5i849?>#XQLHoRE>(2u9JEvgG*eu0$!nK0^6>d zoR+#y<2In|xFztk(7-N>N=&A@&f+#ecH9(rR%l?CMkOXgUDx6^K<~IA+X1c6fR3nG zy$NFh=NcJ3ZUd<`Iho?f3eLrK9x>D6xDgYTxn?Rwg-Ke$6C7f>^-2%FtlD#qOGM*3 z&$V9J=yj&xMa z7Ef@R>~h=7^L{8G{c)IEzjRd07H@F1C~%Es+Htd$1~*%2aDuph`(*^UDL<|6*r@DS Z{Rf))8eDiFD%t=5002ovPDHLkV1krQ?&$yk literal 0 HcmV?d00001 diff --git a/docroot/resources/images/eventMarkers/C3.png b/docroot/resources/images/eventMarkers/C3.png new file mode 100644 index 0000000000000000000000000000000000000000..7fb28231e8ec4a547566164e4e7e0c349b08da04 GIT binary patch literal 630 zcmV-+0*U>JP)1wSm=I|pt+fgTJ=BW`<{$+TM2qwyA}FL6qoN>+2SL(XX%8Y4JQ%Qu2P>#3 zSaK>9JP6jJhlqg+Z4Nys6a;HArctU%+r)T~UAD8cv$JXO=RGaVH_yD^J3onmzv_xH zLp4z~sDf&-WN<08G)%t#E1R6)&ge+1e6i@WMWNdpd2P zGk=%!4+~42=zGTAU0axbl3?=gaSEjh!=rfs?v2ed^R7rf)621gJGpu@OWh+>+qva> zcr?$@{kP1GpCTFwdA3~pS*QQ@8zyoe$vrx`X3TQGNt`ra_(5ANsssbH9eGHsIl{=m zAU>-4W}eQen*G{;qFeuoT6R!Np z3Lu*!Iu9I=EDkTYYGPo?;M(!1TzkRS6neSW>1w0SxwKmqTsI!iIxXj71iBmjN3Oq@ z{5Dn@U0~-y&b|)3;b&nznforP>q_?L!FTRoJuQ|*ms%bF6VCr{c;NH?3;*Dzc#Idx Q7XSbN07*qoM6N<$g2_1|8~^|S literal 0 HcmV?d00001 diff --git a/docroot/resources/images/eventMarkers/C3@2x.png b/docroot/resources/images/eventMarkers/C3@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..55d639b85419c17e05a84f78962daeb773f6a84a GIT binary patch literal 1347 zcmV-J1-$x+P)XjS^aIFt#?m(udl}TVs4s(uW$; zq&78Gj5bxIP)kj0sF2#GRjZ+1KoCK!6id0IvcN(g2rIigvvX#5c9+Y3Pn+5EKmXr+ z^PQP93ouKi(_oGXZ}fJh*jZpr7@R563fNYvSkw$w1n#RXa`=73vj;@_2r-F*JA_sO zww1sMC~}E_%R;LKww1yOIAl`-mxOi|*j5fsK$g$X!F(QpR+v-vYRiFp3|e4rNft+r zi%zVW)bx-7w>v{mKr8gJW3mPJDzrjRD>mV%%rs30pAl{=^tLeSqz*bL0$%ryH}AM7h!e$my4ae^6@D~M#t@H{QYzs z-uXOho?50FToJl=z(n?z@9F5e3&5teiM+c$mAEBg+`2Qw{>n!7RWvX$0kJWmoGr^E zD9|4O)2NxW4aex}9VX16=g7_%$V`c%s z38W_&0cdLPXUuGWHRKc^I~#bV@{%~IfSl_pYFlU$E+fUB5Lax z{G7HZk1a1h{ zlk!L;0Js0WOG9g~?AV;*lWgDn2Ul9;#{h(e=y~ax<-E5c4VZMk_Ckj!xA z&!uJcl4A~>XyUi}JA84xfqfMXmgD6$H^|&r!SdWA9I9@%sxgm0c!cx~jyMl4_Zs!a zx@61K@VnD(Y}rxArRzPIM$L5f4zv4UJ=^yD4opU?WOIh4HZ9)BjoSnC4~RWK0T5YPj6eBl-Olbmgb3NsW)RI{wL*fAZOp zOR{xa^HL}+&JqRJsR9QWGmrD_sTQj0Zt_=iH$4L;^a1|FFAXCnJC?2aDHsEK%Y zmqK0yyISh7lnp77L_S^)O9s(gRo3C)PQRm8CTqfnYB1o#ZfJ|Ca!A{GiMxk z$3j&-=S+%o<&c_7FZc4RR`bqrJBhfi<-Ok(a4i<8({s$*s0c4otrq2!=B?vG za>s^D3vg%d=`Js9UsO~`lf8~s;IeUX*9(U)DxQ{>;3C=8Y%j0%OM%iq4r|uW7Zp!S zYj8(V&@`4l$2~0{xToalv2y`f8My!lV&wwd0VWn^Htxa50bpyG(wfw&(4pH zFd&;{e1<#7IXS3`MZrmEbFJHTv3{x&`0ySWLn$K?ncl*OOHfUMVknr4cktN9lM+}A z7pq7$>%Qh%+vBdZc+Y*0bEWd*y>Tex{Zzqh8Xg154}fIJB?smNE1z#)?RvE+;C7fq z`B`{YS(uTIGR#rQ0swH?m#|rm)P8_&{FA2>n|FKD8={=%;O)z#YVTK#;ap%WxZC%$ zWw1_#phH#u9kV-?$M+W-In M07*qoM6N<$f~AI}w*UYD literal 0 HcmV?d00001 diff --git a/docroot/resources/images/eventMarkers/EE@2x.png b/docroot/resources/images/eventMarkers/EE@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ee257ad67755b2a1bfbf7d6be77b367db5da143a GIT binary patch literal 608 zcmV-m0-ybfP)rYM}784gd3QYmK zVoZSK;(!OCrNFL;2}Ic_a4&Q&u&c-ff_#feJrii6X7JMvftx`yYI|8?j&lpENz%gy zKA52eG|_9o}*!iCH4ZMB)#_QV;tLewf3g>74p5%=6 z?C~AYa8#Cmk5RW>y+5yTd670}>DbNs)OCFepljCg>44YVNkZ~5Ej{leb#?a@eC^?8 ziKqLMB4hh%HY!PuTka|$HS73vz?nC3Q~8+GcNHMMJLHV*tJ$cqPlvTMxzk|~d<=^; z#?S}X_{q7n_KeY})O4Nd?ScT_a{Gj+a=d z;+kWMib$#8hi7BKM<2TUN_BLO4+}9|SN+D#7^|^JlWQDpRM?ADYf2B uacj-s)|$ZyssH*_0^H&-@`S^Wa~TC5uYSJLy0mAo8edueXzj+&Q=IGK$HdOhGQ6vY$!H>#Ue%w2D;&d3?;?_ zCQ1#)rygcFsgXy>g8#^d6LbZ+;6DsM*x>MgTAB8LWHA^%x5yf+yodnX|LhR?{|`1e z{C}{)0Y#1lmyO5gS~5rqv*UEp@wt`^;zDc;n-0BVDCob=;AkwwV4}&7q6Qu;6XJf)J2dB#|laIsM25Ni^8@i0J%8LuJGnB_FG3YAuVwgcuu;7ztU_c5MJXx9~ zfNTy?!NS16z_74RkHOwZh$M?(*`Ms(i44$u&xnzUs0m<(!wWES!yet$$cE!nNUFhb zfNVJ4DvJaFHwWG2BnJ<+;J{@#K8+*;Y=#pZJosFV%WxEhxLi++uc(#IvHBLD;lu_H zPIW}N96bn#jcB@Le_Vi2#NrJ)l3h+p+9$?vvWo>u!knO`#JUPwi;SS&0ZlD1fbL47 jd^{*zKC}%dIlTh_d`56T!Fb8R00000NkvXXu0mjfsSm#f literal 0 HcmV?d00001 diff --git a/docroot/resources/images/eventMarkers/EP@2x.png b/docroot/resources/images/eventMarkers/EP@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..42bde750d96e02a23c4c856d37a1b464d076a170 GIT binary patch literal 781 zcmV+o1M>WdP)5tgeVy-l!&KL!kc7dhYm&&1s%HR)r%)@9lQyF7yB3LsE43a;2{VM z_LRyVqz+w*4yhouh1;cBcV^xn&->0Z?>naVc&<%GaaV&k3|@8$bIZoY}O(r!&;E6kQ0j;T(V2Xo#h1S%J zu#uyZH?@Q3!fj3M7Gof~C|bDmVh?ZLe%Frt6=PEKjvqK@a*0d zRL(_5w_wHx08k;3WTa*jI6nD{TKx#M`VroK_<>ieHxlq7h2ToJR(3 zUyW0IIylD4%Q_Ceo?vJ9CpNdg;qjf}2usZMCV;?ebDr<*!Oe+FSbkbr@HH;Y9TUp3IHl(d}XVqW`i2=b9>Ml#AxHQRjdY-^86t#Kb;T0R3d) zP+_A&J{|g!>~vUI$EDzTXi^nJJ2=Nr&RMI)sEmqF*LAhy;0f1#z)NaI*gC2?y}C|u z8<0Eh1)d5GbXZhEId$E{Z9wn16L?c-phKe)DpS{O+y>~5XL1~{g$7bY#rGl11e`T8 zY1{_#)tp9gGzDjIog-#8j%U%R#(U-|Dn_yek2qxV=w&azrs_J!6Qa1zIs4Oqb2O>- z9$gz1a+7j3WvH%ooTMxcWE|i&Gd*=XK^00000 LNkvXXu0mjf{|0+Y literal 0 HcmV?d00001 diff --git a/docroot/resources/images/eventMarkers/F2.png b/docroot/resources/images/eventMarkers/F2.png new file mode 100644 index 0000000000000000000000000000000000000000..cfd3ce0ce69655c14921054cb204d9195598a00e GIT binary patch literal 504 zcmVd21TGFiW6>r$NVJ5A;BavW zuAAT#E^R{Grh7f--t(!^$2(m;@B4e7^PKlwCD4bjSR<-J)uIYg@UP`}w+6;W!` zAPSFxS*VZ}Z$x4bQMe9j(qL;-uqr=*+a8{-fzQIVD%J@CfgO*Yhl5UsODv?*c&SwI zv0UP$*TeH{hTTL0*ZDk-9A^b&{NGV^p4DndCzITF?a2hM^EoEfDq60Kvwk0kjw77u z&=ylW&T~1u*X!6%rSR$d*d{!dT@Xq^D8<`iAGMRv&GszU1!!Hw-v zsg}XHM45V%U47S_3wNu6o7yAIGg2P4!1SPhAYnz&? u68$67*8b|W1QAtfwBtXa{C~r(=lu&HA$f^E;dhJx0000=oh z8A4-1(2X|}l`%}vY8N4MvL9X4*0yur^S1n+PW-8j3t0Zr+p zVX6do6q?erjEy-e)uvMLs&JdqyU3{M9VFd&XJG-sSd7~A>EIxq^!Jw?`?kN2XKQQt zyt9K}hle=p@nFE~#pAv{_}kiuwRGlq1pr)B3IY1Mw};EYAkx`v@z{@43gN9Syxrc$ z>(No%>gwXESpcWHU8%_ABrbWqOik`BEg_xFqDfH@8X3Wj&Q5$vBrp~ZBa_SF(dsG@ zJRb;Dx}FE1*giOu=`?<(QaJ5)i1bh)7Ve7M@)4giQHlSro1Ty<@a$~lh* z@u?}@bBCGCe-MRlYoKvl)=ctda}!^7cL4wwTU&9;?IvEU$7I^^fnbn$?!&|cu61+} z&%ckyad&YMz~3q2#n2FUe<`y{sa~zGwn(8mj@tJ6}2z|!m!Mv*CxCeQuUBMnv^qWXBzWXN3mpEGh*_b)Cm;fbO^ycwT6rPNPzgt*)DK8(??bmgRt1Xuv{LoL+?S zfD4U`6}N%v8k}5l#Waomo9%3ZUXqQWJ!;AIZ+eD$QwFRzZB;~KHJE^_X- z0xn{ZnyztdqryB$MGeY=j;-TNvSUFe0^H1;Zh2rgqQW5|YaLPG#JJeC;b=rfwnT!n zWLMZ)Ui7Ab_Ft8-evPQemS}LJCplp(GyXMPdw8V) zqnqkVpRp~cEEIM72j^aX5;Clpn3=ZfylPQfu3W2o6nsO5n(d1usyTS*$f+0X>Rol7jFf{*)5H47ls8i# zJm?>}{+yK#HoFDAaz*V+;H_`cD>*L))xnj|!LL?JZB5CdE&K`P{~NBZ_fL8(Z1zw? RS|9)b002ovPDHLkV1k=V!9xH5 literal 0 HcmV?d00001 diff --git a/docroot/resources/images/eventMarkers/HY@2x.png b/docroot/resources/images/eventMarkers/HY@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b8d95ad517c37b4e57b83305695d10f544fe8d75 GIT binary patch literal 786 zcmV+t1MU2YP)NKl|KD8yn3k_3YQLBg*9!&(p|2ogXd`3>+37Doae%X$w2OEFpv4z|1iu^D0L- z2rg(Buu|1ow)_CtphJO`>elk{oiIU{1y(4G7{-o)Ww|ua_BD+iCh&C7?zOcYHphJz zrc*!-9k@C>1+=}E(pUtq7usG^YV?mv!!!-v47csI{~FDXPft8L;?LSF172SA^i=M9cmUqt`Tl4GtgXrYDhn$f z=-sApoH2&cNc2BF@wTrohLsiGwz0wR{jFbH<9fQM%K164zRvg0&ba$mSK@xSzvoxQ zQ!02>Pm=k0;P#g9zr6vMm%#NkKR!MNc6Q8jEllQ^6r7tTv2M6-lr6q>R#5`Uf zml}S6Zf=0ZMR9y_0W2>EFB1+v9`j3A+=fHH`KznI)|NQl-w#FrI`Wg>DyiUpU*c({ zVd{|MEz$HfsP=s&R%%Q#pv8`x!JBZCS{SCm8$jyRAEP}gb-M1TT?AK0*8#7uDK&QL z=B(Fs54QocJD!Hn4J;H6k-|=d~9SIGTiAv2c zVIknOktyLe(5#y?$c0dF8rKP87UOso;SOH2Nl^(>EV%4Y$fY-Y{Dx}g99QMzI^naw z3^)Zv^cpi875|%*=%&myvyS_zf)_Fh@R(DhmH+?% literal 0 HcmV?d00001 diff --git a/docroot/resources/images/eventMarkers/IC.png b/docroot/resources/images/eventMarkers/IC.png new file mode 100644 index 0000000000000000000000000000000000000000..03810830c4205c893defacf5063ecb0e0ab48503 GIT binary patch literal 508 zcmV!xP(-w}rHW`=dbZ1l8iB zb+2-h#YJKY>FGgaW}~>?T0*T-6uNgg7wMz$fXmexg!%?BvX{p2_A%-&CFGBGg*iS! zRhJ6s695eTa|sxMApD^)hSm;Taj|61njW(AYUv67_6`_ zInn@t?y(6lpC9-8>!`n$Q7b>=YI6a%J1b3bb`IHjAl!-Q#v$JFcQ~IPMRs`{%rMY9 zxhTvrkH6>BX_sU5jO^TtOt|#|Q}tdXr)xW07N`rhWQR*XSsJo9PUqU~;k82qmnCY8 z2Pd~j=2`@A3KY4gtm?bwoV!{XoZ22?9Lc$}1=NlHk>f9tYGb9X({`>i`xLn6*Fq~j yR~DtZ;{A~+bANSOOgUYWR{STN|8KbVyng{%QhTXF^NL>p0000hS0LWg|e7{DVr6zF0@nNLYYj!kk4;Y&l6~inug!D5x8g20=0En%p4aj ztV}XJ^uUW|=moSzuOg;G@RCAX^vq)uj><;UdhiY5wncA^(Wm#O$%Oz6f7C+y#~uKl zIoHKwbx-S#`!Bmds`qOq2W~K*{DbApG=WeBmDLA{Hat&F(`f>gVbcld+~a)!J4~-~ zpt$b}e`P+y?C2n~ql2Wrzs%v*F9;tvBwarj6=AnUw48asukEKb=-6|w zaqN>`j(;;k{bx6+Ie7+rJ{BkLG4$aXR+qAdwRBk4)`Kh6*teZzr&0h^z3@79E$0Z= zK7~IRVpsJOJo$DTwWrhhY@ zN5%ht$+guL<>j?DstX49M8Nmx@sOSRM|F8k%E1E_VZya_0Awdq%q2$*$NuuctNhv7 zPA2iYbRSplD*K;(S$lo=%BQ+x?)84fgWCg)^<3odYnP;Z6oCt##7$NG;v3r2F#S^x z!{^^6b7z><VBi|pa;7Hq-40W6(llYBve>ds= z?xJzQJrRzyUnUgUFWtkny-Fw=c(;iPoQ>y6H`J z?%r#wzOCJ%monNNZi$0Lo>VMSr!r$$53b@TXSby>nntCRu3Pmk1TS*E6z~Q;^Vqi3 z;w-7_B5s4R<0XL?g@!E_m7F|vUB+#&cH9$qS!mdzQOU_w*X_6s;*Ptr9k2@x4x&=( zO_&V0(#SY*8#dPBl!~J)xDwY@#LSN4ZY)&knj0x9QnCx4=a9@-Z*=&T)wXlINFuJQ zy!Sf+SFuQK*VwjE5niOK7G+M`)^Q=Zu_03dZs(rvcwv{LA|WMv9aZ3ladGRwQI3k& zQVA}SU1fWD)rSJBe;iiUuN)Pxr5fBU3QA*HcHC=~!M#=)+(OEK{c;1`lRvF*+Nf+= ZJp`z&77+IA^!oq+002ovPDHLkV1hkE4QT)X literal 0 HcmV?d00001 diff --git a/docroot/resources/images/eventMarkers/NR.png b/docroot/resources/images/eventMarkers/NR.png new file mode 100644 index 0000000000000000000000000000000000000000..fe74e35adbdaa5e91eaeb19dcbcf6c47a7ea51ea GIT binary patch literal 518 zcmV+h0{Q)kP)!{9L}f*+m$fhY-rr@#`ptJ;)K ze}S;OCz+&h@BMmu+YT`JMUEJA6}o~hXb{T;H$pk*a$K+1gC@}71+WS=$uJQs40N~; z8ZyDxt6*2YfX8m0Zvs-`UKh`b#514I2>|taJ>r}b=bW0&CS_ShoO2l$Pq|#CR;xw3 z-A?AK@q%*z*lad991d1Dd7guFj_r1f)9Hlea%s$yaM6w=Nl>fRFr7|uyWPCYzTfYt zR4Veb+SYG9`Fg!#GMOODGOSiBFjl;0?RHy^JDm;&gMr(*5D}r#XrSBeVzF4@a=8?W z@;t|Wzek#;NRs5ixe=~kgzK74^~~!09TISPBpk0(r*zjU+?5y$mQvx)pR5Q;j_y3V zJ*IZp;I6`Gnc%_gak;j^9};cuLss>!b8cL%3m)1YYhI9Zy#=8g{gLZ$lW$|4t&4UZ zbM`~vPrnvk>A82(P*+W-In07*qo IM6N<$g2x)`hyVZp literal 0 HcmV?d00001 diff --git a/docroot/resources/images/eventMarkers/NR@2x.png b/docroot/resources/images/eventMarkers/NR@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..779a1f18ced8f3a8d555ed4f8371f3a77e318ad5 GIT binary patch literal 1036 zcmV+n1oQieP)jj^2++XYs_V5dkG zuvStmGB;ZSCzDCj>wSHFBI5`#iGrI#TLEh&Fab+05pY9jSzxU!CSb~D1+EM26j&>h z2^jKuP5QY4ZPC;4X&ZsN1})HAm&MF+(Z3AZiAE--rZepp<%y1L5Q*%<(#P>9phQ<|EZO1d2!91x900l2xjp}oDmaILsr ztA>UK+S=Oa@9$@Fa*}X3Ty~Gn9`6Cks60PEb9i_tQ(~&GuP@T+G}qVHEG{k*i^YU% z^WeH}>nRqC@%s8|3p5^&lT0QHU$?intgo*Zp5X58F6nf-q(+A&TMTZguC6Zpem|K^ zhP}N#Ti{v+0s+Rx#uy$R269h8bX^I9XR}%6=jQ=9IyxeiO4&fw*T=_4;rrIsR-u|Q zaHAQ&zrQm(J4-Ma6mUdwXMNXGixwJtjw|!bf1bLEQ0*90%+|gM+A4`w%7rt~4@E+=kj(oKkU=1y|y_ zikR7Pyb=plx@Rp#MM`$TOB|AU^hPhgvift5mr2BRm3zMva21Qxc8@p+7?(=DaMYvXwp4#7MItZp zAj3^in*<-vf_3v_xa;Pb5-<^Nbdgmo{tyu(BDP#ES*z7zg+hS^LBM*w9$T;1d^`vO zCdP^J;`fB}%jJT>V1RPDj3|mQpU<(|?Jyh;!Pr;zR;v|=2%Sy`jYb1Rq+BtF$AeK6 z;qiE2JRYN1EFug;j7B4DHXCfWTVol-Bnju|^BKPHBcIRnca=&70B|~;jBus%Sfx@4 z*XtGc`<=f#91Z}0FbuQ7<3{LqyU68om`oO3pwatUlV`Yr9B zCey}hOXuv|rPYf6g!BImcRuf*L&2d{PqSwJ P00000NkvXXu0mjf@iO-` literal 0 HcmV?d00001 diff --git a/docroot/resources/images/eventMarkers/OT@2x.png b/docroot/resources/images/eventMarkers/OT@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..3d1993acae79466535967de6132a4eadb91b7f8b GIT binary patch literal 1046 zcmV+x1nK*UP)B|I*N;{LqTwGRd5ssEebkxaS~BV zmg*`Ul-8jtO6xmF^S*7qF6kxpqtA4tm;e3p7`vX6-2zKtaC4F@ z;95CianjiqIFrd({yscBEHaJ|lPI_)v>kA*1TMgqO9b2$+9`0YY%ai(%?{iUx?bQ~ zSzLfA-ye&5zChd5G`-tq;J!f%)HYRttyUNy8Zh4 z%Ixecx3{+dOiWC$w6w(F;2_0fk+ZWilF1~6LIHq{jSZ%!r!5CQJv}8Jj{~rFn$z8jZ5EvqMWu3jmv&n>;^1ikbY6^g-rzg(O&n05&r30^xNwHYu?(PnNo}L~C1_n%vMIsTc zeRXvui_!2wVe9zg<0Bs*pHrZ}zu&TNFc{R@4-XGc`5eg>ypmghQmJIw_vPi~U*SLA zzT0f8cXxMd5cl`@mVI-%oYwB`?X45dTDIUF9UTk}4FR9Av3iMIUS4YLk&zJzc;*ti zpV4SkYj16BQ7)HteN&U!$!z# zb@(GT5>*oXT;{2)82$&B@yXe1X^fUpX{76RW7mS~TyF%t$(U7aKWcI|)O8WJ!QAnN zz>7k|4~t4gzPj$jZLoLT7kH=8@I#|gk*BV^aT~-P_hdQX78>e^N~0HHPQax`rXIK9 zYfVl^adZkU#dR4mbK|%d6V-9euM`zWatmJNkds?)w)u6cU+1_^BCgAP_SXY0W0JbA z@oS?ZJV|9u%8Gui<3jRcK_&y-%`?63iQSBf14&uy$O1Qwi&qox^OmLCxN?Xgz z-W1sVjpr%o<5wWmHrA;~u5d^og2{u+K zg6Lu^x}69Xid7t|9X31nCU`suS@Qq$&6~F~yCKkvDr!VEP*qexjaVYM5bC%dyPnPr z7)=m{bKoFUB*8?4y9mQ|P?HH_je=GEKHT=^=_a5-xYk8fQUBw(9^F1VrorD5$rPPi zUZIZb3F}?m37VUkrx&-6)N#GK8t2?ssa(P3-4m8JcW`!6#6z{h*YBUPaZtq7`6;S5 z*BpbvWpRAfY5;(?_BdA~gs|^KGR1LPWzH9~lK_C8zBKad1q^4#g>?X!%H}XQG{P~Y z@KU*g-gLjL%<6BiFDT?^ak9UQ)xE>I9u|w@NGlbKwEzGT*BZ@-?R}Hk5 z2yPsY%C!t$*C=yuvZ`k7bKz=LaMO6CeMZj15t!cSA36Rq>29ntI&0@Pd*1{e`LWPQ z=GsY3U5WmYX~+KRZt)d%X|&=$;rxHYt@rykmzRY!3;|$}00000NkvXXu0mjfY-Z~o literal 0 HcmV?d00001 diff --git a/docroot/resources/images/eventMarkers/PB@2x.png b/docroot/resources/images/eventMarkers/PB@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..91dcda5ec16e6fda10220f2e2b890b64e90c4d6d GIT binary patch literal 1020 zcmVc+s6?B>!K1=$h29*aMeHKKu(|0do^-hdvsf$^Bqk>#eO)G86;-I% zvmZ8_En%;X6=WzB= zJ*KCpiR&lE$1wQ54;{Cz;ACw%et!E(TpI$Hbepf1b~il!A$a^l=**k>een7tu0Hf)W8qc+!0?an=)8SRP$R+;3-^=Gj zwQ*J}(lVTAJl@1z`#jK3+?&a+@@Hy_`+0%gPE{KWd)6tARN^`TIL%HnH8}~tuLo_d z7rATs#U(_we`jX!WpDs(mzw{BSgk0pYY-hP#$*#2C#$B0U6P-EEH|0~q ziPeq;M)B%dH!hz)17Ein{cn2F>VAxrWy1ZOFH?WzCoNorqOwZV96SO52>cqs(??xI zwb5}_3ENsmM}~w_mE4X50Nbl~Bd^Fs zG?$^^eA(mUvBZ&zwOKhRtKEwNS1EBV(dp1k>2f+WQblN;ovO3a@JcMqij^n zbX}=8A6($N8SoN4BiQED;xyHD61M?q$4!AJg$CwWR6;V;bsDz;WycMHr-cUQXjDSd z)pa#)1LThDavV?#4QPmp*@rM1aHf&b;x-Uni<2sjwBSr!XAv_sj_a{dsppKQs8C5Q zc!WbVk6!BKmsa!6ae+u&XSw%l0cWvDRnIYRqe8q$SuM(t=B?vI(&IqJ0$k0Wu6bd{ zqCz1iXB}4Hl5x@Ng(DUfqs0=OB)iPc@~mG9l>Tv;S-)6Rj23HfxhOD=Wz2D-6$3X~ qF>nPj|Mp7{a6|sIK5L^gYxNJ|s{fB*8QVPo0000gL57bQB(Sks}2sj{W`(&3a=Cv_8dnJi@e|0036Y25o!jfs84d|4FFpd@+O8C3d@4 zygY9)y1c+}c#ipE1^{T*8?@~KH@8D9M7H*os_%gx?(P2pT&D%gs`*`KTg zS&r*GIUZjeAvm`%Sw6Tp9?o?L{;4qJUNY5JopbNB99$YtuukOMjX=85KXUy;ift?# zU9$6(voC>Xe-@6(Jla(1%J+{fy7pI3i;?S6H1VHs{=ebL=lvaL*-qm-k^Qp_6<|HoQtc+Mp-mC@w^7T~t`uA@? zD1AswRB$D<4mc~q1!%b_;8JK);H)$kpk&j53!zPokPwBu*zAF;Ky0RWiK6FfXT;r8|yo@Rdm0H03YV=(9m8;(X}Twe_V0EdTt zd^kSPLW*2>9?o{Vg>JWlqocj$vB~(+wX9Zbt4fl*+_YA!_{A#M!E_}_k_40SBZkA@ z%VWLXraPI2$!a(+FYctT^>?@NcBgAo)<`U?N*uS*dGi|kgC6!IL&+^G@YPi%lxp#j zUn%9liBIBQC1R?N<5dxPcNj{t-C<=Nmx7mJNL380;KgpBd9-9yMkS={y53pvoa-Ur zB|TGYwHnT#u2b9w&#ih)@6I$Yq=xD@rk(L}|! zIKe5|W!uYhKNRTxILy|siHdJ=gR7#z8q3CU-)g{ps{z-D{oAi1z`gvmUfHNrR{sJ0 Wg~VX5J$%Ff0000jcW8zV4xvf~ zK`7ZQgib;#LNEq#uv#Iz9MYjw6ilZam&?0%cTrmVpI-95_xt|ud*6GBg16d|IYqWm zHYtPpVu|2FsL?d6aVb^umB0z_0o$P>2@)wi11H=I>L)++P z^{vJBr)_fYb1c*rNZd=X`C*gxMR(N-)dEJ-AYaLI{Qa1Exh`~W!HuTDpgIV^LG=Lh z$RztFOTLmv8F0UA?Ta>QM72KK%Nnu=j;2SMc{RgcWsjZJ9n@hJ{fW-hi>WSP`F`0N zk3AS;^4X*?ame|FKCh$C>zw^Mqp{LpYjKNYHc9+$+=?kw3urermL4vl#Wd6#suKg) z%2G;|sDG@poLlCkbwX$;MD%tPfUp+sb)|(!KTos!c^C7@#Lf{qpLjfh`NL%G=^E$1 z&xziOl6jqBMp-fb|qIM*}ZIjO#Hr(KR`KNUx8DwWf< z9WDzD3btg2OMkKg$l^Gi2QH7O9By#g!oU*2eV0e(+6~?n=;q$fuCA)jg}ar({g%fy z&X;p%3H%=PU%CEn@;X>)=>j_s())hkeLoAAskvuTzph08$UN8n({Aa=>C)GZuZ8pf d8y>jd{{V1uoutuUg^K_H002ovPDHLkV1kEsIokjL literal 0 HcmV?d00001 diff --git a/docroot/resources/images/eventMarkers/SR@2x.png b/docroot/resources/images/eventMarkers/SR@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..95b2ad8090041cb5ab7da9124fe2318989a5b415 GIT binary patch literal 1348 zcmV-K1-tr*P)MX?*L8`C^Hg zMvdXcNQjf5Q8SZm8aI*+VZ&vEAi=;IAYl&JWc&+%4ruEOq@nFy@40eq`S*Qw?LE)) z``q`w&)sv^!U~mSz!=_G)Lu&GaDfeB2&a=#z=6`mqNlMY@X6MbA@`qs`m9JDAtq7q zkkDGdff6_XO)e2|RcO7yfzmjDkZfAuiqK014pfK(P~|g)7%wW&8e^)SZ8h+yK?{tn z$P&tN(SZ$H|xg;hh`mqGK?gb>F$vN0Q=-b}+ z!9HvmHsZ{2ByCAz|Ec|?7p7y&uwmI^K^~Vyh6KR8$xP0n9I~Fv0_M-a;##336cw|@ z?Avx-b>TSb@Y`R0u3VH=QSKSHZ#%)3Ak-(wai!V3y=1#@yZ&@NwXfFF^i>l*fAk=a zi8~x9d0eLBmkwN4T>x0@7E-cPg4!uD2|1o~D2E}}5O==4gV*C_uw#(Hj`>q6nI!Ca zcJg1&XUE|kA=jIAm36B7;;r$Nep*VLSqMoCTrx=%zh8{wsDsX5JL&tgkAe09W+!Ly zdc5?v_0!+hPftS+WoOES3NKc6SvDmfmJn}^7w#i=c>l}#gcav;C(h$eydEzPyB^Yc zxs#UjEqLZU-2MG7y^Xz!*JJR%storIQ}bdCUXPc7wgEOgzCjO4__ut?B$1k(O7VNe z9QgQv@B4wj288pYG!J9O1N(jc;GG8nr`KO>wQM=jnZ1%%8jpYFnVtk_lz5POs1!yhsMtveeKm^ zqNJqMBnnLPQ~b2L{q(_Gc8*ml<&yRhfm zg~0)wZ#rrJsU3M-rv2x33MvXn&I(@h1WG95+lseQ`C}!8#|lZwN?~K-MkJF&Tznjx zpWIB&;T+1(l~eR?kuGSMH)iMSI|1-|ysB{wlR#ICXu8IcN{|ECx5? zm2;%!V+W;?*UKAQuSyUDjRoC^n4cd-J z1zs;StkS3~D59>1<2Hyp9+BlhxX`eKsKj~^rU%^6$SlQeSYDG;R~+?%8{)bVF$>4> zNK91SV=kwt=u)`geh%q5^=c2ldbQ>p50Z%MMqc}u0&c`44SS3=8x`S6YSg4$(3*8z zNRe2O837*7HGRnwdp#;TWXM{_C~(!dMCyTKJu1DcPHEbyAfT>)_GWI=F@mfBO{)@TmN>zG9=YV)Y;Yxt2zu{XvBQ0000 Date: Tue, 7 Apr 2026 03:32:51 +0000 Subject: [PATCH 35/49] remove intermediate event timeline --- src/Event/EventsTimeline.php | 248 ------------------ .../Composite/HelioviewerCompositeImage.php | 3 +- .../unit_tests/events/EventsTimelineTest.php | 147 ----------- 3 files changed, 1 insertion(+), 397 deletions(-) delete mode 100644 src/Event/EventsTimeline.php delete mode 100644 tests/unit_tests/events/EventsTimelineTest.php diff --git a/src/Event/EventsTimeline.php b/src/Event/EventsTimeline.php deleted file mode 100644 index 8b6c88e86..000000000 --- a/src/Event/EventsTimeline.php +++ /dev/null @@ -1,248 +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 DateTime; -use DateInterval; -use DatePeriod; -use InvalidArgumentException; -use Exception; -use Helioviewer\Api\Event\Api\EventsApi; -use Helioviewer\Api\Event\Api\EventsApiInterface; - -require_once HV_ROOT_DIR . '/../src/Helper/HelioviewerEvents.php'; - -class EventsTimeline -{ - // Threshold (ms) => resolution mapping for timeline display - private const RESOLUTION_THRESHOLDS = [ - 86400000 => 'm', // < 1 day - 172800000 => '30m', // < 2 days - 864000000 => 'h', // < 10 days - 16070400000 => 'D', // < ~6 months - 40176000000 => 'W', // < ~15 months - 157680000000 => 'M', // < ~5 years - ]; - - private \Helper_HelioviewerEvents $events; - private EventSelections $eventSelections; - private EventsApiInterface $eventsApi; - private int $startMs; // milliseconds - private int $endMs; // milliseconds - private int $currentMs; // milliseconds - private string $resolution; - - /** - * @throws InvalidArgumentException If parameters are invalid - */ - public function __construct(string $eventLayers, $startTimestamp, $endTimestamp, $currentTimestamp, ?EventsApiInterface $eventsApi = null) - { - $this->eventsApi = $eventsApi ?? new EventsApi(); - // Validate all three timestamps are provided and positive integers (in milliseconds) - $startTimestamp = filter_var($startTimestamp, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); - $endTimestamp = filter_var($endTimestamp, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); - $currentTimestamp = filter_var($currentTimestamp, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); - - if ($startTimestamp === false || $endTimestamp === false || $currentTimestamp === false) { - throw new InvalidArgumentException('startTimestamp, endTimestamp, and currentTimestamp must be positive integer timestamps in milliseconds'); - } - - // Validate chronological order: start <= end - if ($startTimestamp > $endTimestamp) { - throw new InvalidArgumentException('startTimestamp must be less than or equal to endTimestamp'); - } - - $this->startMs = $startTimestamp; - $this->endMs = $endTimestamp; - $this->currentMs = $currentTimestamp; - - $this->resolution = current(array_filter(self::RESOLUTION_THRESHOLDS, fn($t) => $this->endMs - $this->startMs < $t, ARRAY_FILTER_USE_KEY)) ?: 'Y'; - $this->events = new \Helper_HelioviewerEvents($eventLayers); - $this->eventSelections = EventSelections::buildFromLegacyEventStrings($eventLayers); - } - - public function getResolution(): string { return $this->resolution; } - public function getStartMs(): int { return $this->startMs; } - public function getEndMs(): int { return $this->endMs; } - public function getCurrentMs(): int { return $this->currentMs; } - public function getEventSelections(): EventSelections { return $this->eventSelections; } - - public function timeline(): string - { - if ($this->resolution === 'm') { - return $this->getMinuteCoverage(); - } - return $this->getAggregatedCoverage(); - } - - /** - * Get aggregated event coverage using EventsApi distributions endpoint - */ - public function getAggregatedCoverage(): string - { - // Original request range (in milliseconds) - $startMs = $this->startMs; - $endMs = $this->endMs; - - // Expand range by distance on both sides for smooth scrolling buffer - $distance = $endMs - $startMs; - $extendedStartMs = $startMs - $distance; - $extendedEndMs = $endMs + $distance; - - // Convert to seconds for API call - $extendedStartSec = intval($extendedStartMs / 1000); - $extendedEndSec = intval($extendedEndMs / 1000); - - // Get selection paths from EventSelections - $paths = iterator_to_array($this->eventSelections); - - if (empty($paths)) { - return json_encode([]); - } - - $results = []; - - // Call the Events API - $response = $this->eventsApi->getDistributions( - $this->resolution, - $extendedStartSec, - $extendedEndSec, - $paths - ); - - $event_types = $response['event_types'] ?? []; - $buckets = $response['buckets'] ?? []; - - foreach ($event_types as $et) { - $results[$et] = [ - 'data' => [], - 'event_type' => $et, - 'res' => $this->resolution, - 'showInLegend' => true, - ]; - } - - foreach ($buckets as $bucket) { - $bucketStartMs = $bucket['start'] * 1000; - $default_counts = array_fill_keys($event_types, 0); - - $counts = $bucket['counts'] ?? []; - $counts = array_merge($default_counts, $counts); - - foreach ($counts as $eventType => $count) { - $results[$eventType]['data'][] = [$bucketStartMs, (int) $count]; - } - } - - ksort($results); - return json_encode(array_values($results)); - } - - /** - * Get minute-level event coverage using EventsApi events endpoint - */ - public function getMinuteCoverage(): string - { - // Calculate extended time range (3x visible range for smooth scrolling) - $distance = $this->endMs - $this->startMs; - $extendedStartMs = $this->startMs - $distance; - $extendedEndMs = $this->endMs + $distance; - - // Convert to seconds for API call - $fromTimestamp = intval($extendedStartMs / 1000); - $toTimestamp = intval($extendedEndMs / 1000); - - // Get selection paths from EventSelections - $paths = iterator_to_array($this->eventSelections); - - // Fetch events from the Events API - $response = $this->eventsApi->getEventsInRange($fromTimestamp, $toTimestamp, $paths); - $events = $response['events'] ?? []; - - // Group events by event_type and add x, x2 for timeline display - $results = []; - - foreach ($events as $event) { - $eventType = $event['event_type'] ?? 'UNK'; - - // If this event_type not seen yet, create a new group - if (!isset($results[$eventType])) { - $results[$eventType] = [ - 'data' => [], - 'event_type' => $eventType, - 'res' => 'm', - 'showInLegend' => true - ]; - } - - // Convert event times to milliseconds for x and x2 - $timeStart = strtotime($event['event_starttime']) * 1000; - $timeEnd = strtotime($event['event_endtime']) * 1000; - - // Clamp to extended range - if ($extendedStartMs > $timeStart) { - $timeStart = $extendedStartMs; - } - if ($extendedEndMs < $timeEnd) { - $timeEnd = $extendedEndMs; - } - - // Add x and x2 to event - $event['x'] = $timeStart; - $event['x2'] = $timeEnd; - $event['y'] = 0; // Temporary, will be set by swim lane algorithm - - // Add event to the data array for this event_type - $results[$eventType]['data'][] = $event; - } - - // SWIM LANE ALGORITHM - Stack overlapping events - // Events that overlap in time are placed in different "swim lanes" (y values) - $i = 1; // Next available swim lane number - $levels = []; // Tracks events in each lane: [lane => [events...]] - - foreach ($results as $eventType => $series) { - $data = []; - - foreach ($series['data'] as $event) { - $placed = false; - - // Try to fit in an existing lane - foreach ($levels as $row => $rowEvents) { - $last = end($rowEvents); - // If new event starts after last event ends, it fits here - if ($event['x'] >= $last['x2']) { - $event['y'] = $row; - $levels[$row][] = $event; - $data[] = $event; - $placed = true; - break; - } - } - - // No existing lane works, create a new one - if (!$placed) { - $levels[$i] = [$event]; - $event['y'] = $i; - $data[] = $event; - $i++; - } - } - - $results[$eventType]['data'] = $data; - } - - // Convert to indexed array - return json_encode(array_values($results)); - } -} diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index 7bf3cbcb1..04acbc1c3 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -601,9 +601,8 @@ private function _addEventLayer($imagickImage) { EventsApi::VALID_SOURCES ); } catch (EventsApiException $e) { - error_log("[CompositeImage] Batch events failed for {$this->date}: " . $e->getMessage()); + // Already captured to Sentry by EventsApi } catch (\Exception $e) { - error_log("[CompositeImage] Unexpected error fetching events for {$this->date}: " . $e->getMessage()); Sentry::capture($e); } } diff --git a/tests/unit_tests/events/EventsTimelineTest.php b/tests/unit_tests/events/EventsTimelineTest.php deleted file mode 100644 index ae8da3c09..000000000 --- a/tests/unit_tests/events/EventsTimelineTest.php +++ /dev/null @@ -1,147 +0,0 @@ - - */ - -use PHPUnit\Framework\TestCase; -use Helioviewer\Api\Event\EventsTimeline; -use Helioviewer\Api\Event\Api\EventsApiInterface; - -final class EventsTimelineTest extends TestCase -{ - private $mockEventsApi; - - protected function setUp(): void - { - $this->mockEventsApi = $this->createMock(EventsApiInterface::class); - } - - public static function invalidTimestampProvider(): array - { - return [ - 'null_start' => [null, 2000, 1500], - 'null_end' => [1000, null, 1500], - 'null_current' => [1000, 2000, null], - 'zero_start' => [0, 2000, 1500], - 'zero_end' => [1000, 0, 1500], - 'zero_current' => [1000, 2000, 0], - 'negative_start' => [-1, 2000, 1500], - 'string_start' => ['abc', 2000, 1500], - 'string_end' => [1000, 'abc', 1500], - 'string_current' => [1000, 2000, 'abc'], - ]; - } - - /** - * @dataProvider invalidTimestampProvider - */ - public function testItShouldThrowForInvalidTimestamps($start, $end, $current): void - { - $this->expectException(InvalidArgumentException::class); - new EventsTimeline('[AR,all,1]', $start, $end, $current, $this->mockEventsApi); - } - - public function testItShouldThrowWhenStartIsGreaterThanEnd(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('startTimestamp must be less than or equal to endTimestamp'); - new EventsTimeline('[AR,all,1]', 2000, 1000, 1500, $this->mockEventsApi); - } - - public function testItShouldNotThrowWhenStartEqualsEnd(): void - { - $this->expectNotToPerformAssertions(); - new EventsTimeline('[AR,all,1]', 1000, 1000, 1000, $this->mockEventsApi); - } - - public function testItShouldNotThrowForValidTimestamps(): void - { - $this->expectNotToPerformAssertions(); - new EventsTimeline('[AR,all,1]', 1000, 2000, 1500, $this->mockEventsApi); - } - - public function testItShouldStoreTimestamps(): void - { - $timeline = new EventsTimeline('[AR,all,1]', 1000, 2000, 1500, $this->mockEventsApi); - $this->assertEquals(1000, $timeline->getStartMs()); - $this->assertEquals(2000, $timeline->getEndMs()); - $this->assertEquals(1500, $timeline->getCurrentMs()); - } - - public function testItShouldParseEventSelections(): void - { - $timeline = new EventsTimeline('[AR,all,1],[C3,all,1]', 1000, 2000, 1500, $this->mockEventsApi); - $selections = $timeline->getEventSelections(); - $this->assertCount(2, $selections); - $this->assertEquals('HEK>>Active Region', $selections[0]); - $this->assertEquals('CCMC>>DONKI', $selections[1]); - } - - public function testItShouldParseEmptyEventSelections(): void - { - $timeline = new EventsTimeline('', 1000, 2000, 1500, $this->mockEventsApi); - $this->assertCount(0, $timeline->getEventSelections()); - } - - public static function resolutionProvider(): array - { - return [ - 'under_1_day_minute' => [ - 1000, - 1000 + 86400000 - 1, - 'm', - ], - 'exactly_1_day_30m' => [ - 1000, - 1000 + 86400000, - '30m', - ], - 'under_2_days_30m' => [ - 1000, - 1000 + 172800000 - 1, - '30m', - ], - 'under_10_days_hourly' => [ - 1000, - 1000 + 864000000 - 1, - 'h', - ], - 'under_6_months_daily' => [ - 1000, - 1000 + 16070400000 - 1, - 'D', - ], - 'under_15_months_weekly' => [ - 1000, - 1000 + 40176000000 - 1, - 'W', - ], - 'under_5_years_monthly' => [ - 1000, - 1000 + 157680000000 - 1, - 'M', - ], - 'over_5_years_yearly' => [ - 1000, - 1000 + 157680000000, - 'Y', - ], - 'zero_range_minute' => [ - 1000, - 1000, - 'm', - ], - ]; - } - - /** - * @dataProvider resolutionProvider - */ - public function testItShouldCalculateCorrectResolution(int $start, int $end, string $expected): void - { - $current = $start; - $timeline = new EventsTimeline('[AR,all,1]', $start, $end, $current, $this->mockEventsApi); - $this->assertEquals($expected, $timeline->getResolution()); - } -} From 6ce6cf15af7fc2da496b3d9deb1c70a4dbd84675 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 7 Apr 2026 03:35:43 +0000 Subject: [PATCH 36/49] events timeline port with tests --- src/Event/Timeline/AggregatedCoverage.php | 78 ++++++++ src/Event/Timeline/CoverageInterface.php | 20 ++ src/Event/Timeline/MinuteCoverage.php | 90 +++++++++ src/Event/Timeline/Resolution.php | 32 ++++ src/Event/Timeline/SwimLaner.php | 73 +++++++ src/Event/Timeline/TimeRange.php | 61 ++++++ src/Event/Timeline/Timeline.php | 87 +++++++++ src/Module/WebClient.php | 7 +- .../events/timeline/ResolutionTest.php | 41 ++++ .../events/timeline/SwimLanerTest.php | 178 ++++++++++++++++++ .../events/timeline/TimeRangeTest.php | 89 +++++++++ .../events/timeline/TimelineTest.php | 68 +++++++ 12 files changed, 820 insertions(+), 4 deletions(-) create mode 100644 src/Event/Timeline/AggregatedCoverage.php create mode 100644 src/Event/Timeline/CoverageInterface.php create mode 100644 src/Event/Timeline/MinuteCoverage.php create mode 100644 src/Event/Timeline/Resolution.php create mode 100644 src/Event/Timeline/SwimLaner.php create mode 100644 src/Event/Timeline/TimeRange.php create mode 100644 src/Event/Timeline/Timeline.php create mode 100644 tests/unit_tests/events/timeline/ResolutionTest.php create mode 100644 tests/unit_tests/events/timeline/SwimLanerTest.php create mode 100644 tests/unit_tests/events/timeline/TimeRangeTest.php create mode 100644 tests/unit_tests/events/timeline/TimelineTest.php diff --git a/src/Event/Timeline/AggregatedCoverage.php b/src/Event/Timeline/AggregatedCoverage.php new file mode 100644 index 000000000..04581ce02 --- /dev/null +++ b/src/Event/Timeline/AggregatedCoverage.php @@ -0,0 +1,78 @@ +>Active Region', 'CCMC>>DONKI']) + * @param string $resolution Bucket size: 30m, h, D, W, M, Y + * @return array Array of series, each with data points as [timestamp_ms, count] + */ + public function execute(EventsApiInterface $eventsApi, TimeRange $range, array $paths, string $resolution): array + { + // Nothing selected — return empty + if (empty($paths)) { + return []; + } + + // Call the Events API for bucketed counts + $response = $eventsApi->getDistributions( + $resolution, + $range->startSec(), + $range->endSec(), + $paths + ); + + $eventTypes = $response['event_types'] ?? []; + $buckets = $response['buckets'] ?? []; + + // Initialize a series for each event type + $results = []; + foreach ($eventTypes as $et) { + $results[$et] = [ + 'data' => [], + 'event_type' => $et, + 'res' => $resolution, + 'showInLegend' => true, + ]; + } + + // Fill in data points from each bucket + foreach ($buckets as $bucket) { + // Convert bucket start time from seconds to milliseconds + $bucketStartMs = $bucket['start'] * 1000; + + // Default all event types to 0 for this bucket + $defaultCounts = array_fill_keys($eventTypes, 0); + + // Merge actual counts over the defaults (missing types get 0) + $counts = array_merge($defaultCounts, $bucket['counts'] ?? []); + + // Add a data point [timestamp_ms, count] to each event type's series + foreach ($counts as $eventType => $count) { + $results[$eventType]['data'][] = [$bucketStartMs, (int) $count]; + } + } + + // Sort by event type name and return as indexed array + ksort($results); + return array_values($results); + } +} diff --git a/src/Event/Timeline/CoverageInterface.php b/src/Event/Timeline/CoverageInterface.php new file mode 100644 index 000000000..550df3233 --- /dev/null +++ b/src/Event/Timeline/CoverageInterface.php @@ -0,0 +1,20 @@ +swimLaner = $swimLaner; + } + + /** + * Fetch individual events from the Events API, group by type, and layout with swim lanes. + * + * @param EventsApiInterface $eventsApi API client + * @param TimeRange $range Extended time range + * @param array $paths Selection paths (e.g. ['HEK>>Active Region']) + * @param string $resolution Always 'm' for this strategy + * @return array Array of series with swim-laned events + */ + public function execute(EventsApiInterface $eventsApi, TimeRange $range, array $paths, string $resolution): array + { + // Nothing selected — return empty + if (empty($paths)) { + return []; + } + + // Fetch individual events from the Events API + $response = $eventsApi->getEventsInRange($range->startSec(), $range->endSec(), $paths); + $events = $response['events'] ?? []; + + // Extended range in milliseconds (for clamping) + $extendedStartMs = $range->start(); + $extendedEndMs = $range->end(); + + // Group events by event_type + $results = []; + foreach ($events as $event) { + $eventType = $event['event_type'] ?? 'UNK'; + + // Create series for this event type if not seen yet + if (!isset($results[$eventType])) { + $results[$eventType] = [ + 'data' => [], + 'event_type' => $eventType, + 'res' => 'm', + 'showInLegend' => true + ]; + } + + // Convert event start/end times to milliseconds + $timeStart = strtotime($event['event_starttime']) * 1000; + $timeEnd = strtotime($event['event_endtime']) * 1000; + + // Clamp event times to the extended range + if ($extendedStartMs > $timeStart) { + $timeStart = $extendedStartMs; + } + if ($extendedEndMs < $timeEnd) { + $timeEnd = $extendedEndMs; + } + + // Add timeline coordinates to the event + $event['x'] = $timeStart; + $event['x2'] = $timeEnd; + $event['y'] = 0; + + $results[$eventType]['data'][] = $event; + } + + // Assign y values so overlapping events stack vertically + $results = $this->swimLaner->assign($results); + + return array_values($results); + } +} diff --git a/src/Event/Timeline/Resolution.php b/src/Event/Timeline/Resolution.php new file mode 100644 index 000000000..321b1175e --- /dev/null +++ b/src/Event/Timeline/Resolution.php @@ -0,0 +1,32 @@ + 'm', // < 1 day + 172800000 => '30m', // < 2 days + 864000000 => 'h', // < 10 days + 16070400000 => 'D', // < ~6 months + 40176000000 => 'W', // < ~15 months + 157680000000 => 'M', // < ~5 years + ]; + + /** + * @param int $rangeMs Time range in milliseconds + * @return string Resolution code: m, 30m, h, D, W, M, Y + */ + public static function fromRange(int $rangeMs): string + { + foreach (self::THRESHOLDS as $threshold => $resolution) { + if ($rangeMs < $threshold) { + return $resolution; + } + } + return 'Y'; + } +} diff --git a/src/Event/Timeline/SwimLaner.php b/src/Event/Timeline/SwimLaner.php new file mode 100644 index 000000000..b859cb816 --- /dev/null +++ b/src/Event/Timeline/SwimLaner.php @@ -0,0 +1,73 @@ + [events...]] + // Used to check if a new event fits in an existing lane + $levels = []; + + foreach ($series as $eventType => $s) { + $data = []; + + foreach ($s['data'] as $event) { + $placed = false; + + // Try to fit this event in an existing lane + // by checking if it starts after the last event in that lane ends + foreach ($levels as $row => $rowEvents) { + $last = end($rowEvents); + if ($event['x'] >= $last['x2']) { + // Fits in this lane — reuse it + $event['y'] = $row; + $levels[$row][] = $event; + $data[] = $event; + $placed = true; + break; + } + } + + // No existing lane works — create a new one + if (!$placed) { + $levels[$laneCounter] = [$event]; + $event['y'] = $laneCounter; + $data[] = $event; + $laneCounter++; + } + } + + $series[$eventType]['data'] = $data; + } + + return $series; + } +} diff --git a/src/Event/Timeline/TimeRange.php b/src/Event/Timeline/TimeRange.php new file mode 100644 index 000000000..6bf44c0d7 --- /dev/null +++ b/src/Event/Timeline/TimeRange.php @@ -0,0 +1,61 @@ + end + */ + public function __construct($startTimestamp, $endTimestamp, $currentTimestamp) + { + $startTimestamp = filter_var($startTimestamp, FILTER_VALIDATE_INT); + $endTimestamp = filter_var($endTimestamp, FILTER_VALIDATE_INT); + $currentTimestamp = filter_var($currentTimestamp, FILTER_VALIDATE_INT); + + if ($startTimestamp === false || $endTimestamp === false || $currentTimestamp === false) { + throw new InvalidArgumentException('startTimestamp, endTimestamp, and currentTimestamp must be integer timestamps in milliseconds'); + } + + if ($startTimestamp >= $endTimestamp) { + throw new InvalidArgumentException('startTimestamp must be less than endTimestamp'); + } + + $this->startMs = $startTimestamp; + $this->endMs = $endTimestamp; + $this->currentMs = $currentTimestamp; + } + + public function start(): int { return $this->startMs; } + public function end(): int { return $this->endMs; } + public function current(): int { return $this->currentMs; } + public function range(): int { return $this->endMs - $this->startMs; } + public function startSec(): int { return intval($this->startMs / 1000); } + public function endSec(): int { return intval($this->endMs / 1000); } + + /** + * Returns an extended TimeRange with 1x buffer on each side for smooth scrolling. + */ + public function extended(): self + { + $distance = $this->range(); + return new self( + $this->startMs - $distance, + $this->endMs + $distance, + $this->currentMs + ); + } +} diff --git a/src/Event/Timeline/Timeline.php b/src/Event/Timeline/Timeline.php new file mode 100644 index 000000000..e752e0496 --- /dev/null +++ b/src/Event/Timeline/Timeline.php @@ -0,0 +1,87 @@ +execute(); + */ +class Timeline +{ + private TimeRange $range; + private string $resolution; + private EventSelections $eventSelections; + private EventsApiInterface $eventsApi; + private CoverageInterface $strategy; + + /** + * @param string $eventLayers Legacy event string e.g. '[AR,all,1],[FL,NOAA_SWPC,1]' + * @param mixed $startTimestamp Start time in milliseconds + * @param mixed $endTimestamp End time in milliseconds + * @param mixed $currentTimestamp Current observation time in milliseconds + * @param EventsApiInterface|null $eventsApi Optional API client (for testing/DI) + * @param CoverageInterface|null $strategy Optional strategy override (for testing) + * @throws \InvalidArgumentException If timestamps are invalid + */ + public function __construct( + string $eventLayers, + $startTimestamp, + $endTimestamp, + $currentTimestamp, + ?EventsApiInterface $eventsApi = null, + ?CoverageInterface $strategy = null + ) { + // Validate and store time range + $this->range = new TimeRange($startTimestamp, $endTimestamp, $currentTimestamp); + + // 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); + + // API client — use provided or create default + $this->eventsApi = $eventsApi ?? new EventsApi(); + + // Coverage strategy — auto-select based on resolution, or use provided override + $this->strategy = $strategy ?? ($this->resolution === 'm' + ? new MinuteCoverage(new SwimLaner()) + : new AggregatedCoverage()); + } + + /** + * Execute the timeline query and return JSON string. + * + * @return string JSON array of series data for the frontend + */ + public function execute(): string + { + $paths = iterator_to_array($this->eventSelections); + $extendedRange = $this->range->extended(); + + $data = $this->strategy->execute( + $this->eventsApi, + $extendedRange, + $paths, + $this->resolution + ); + + return json_encode($data); + } + + // Getters for testing + public function getResolution(): string { return $this->resolution; } + public function getRange(): TimeRange { return $this->range; } + public function getEventSelections(): EventSelections { return $this->eventSelections; } +} diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index 6b56c83a7..0f95c6dc4 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -19,7 +19,7 @@ use Helioviewer\Api\Module\AbstractModule; use Helioviewer\Api\Module\ModuleInterface; use Helioviewer\Api\Event\EventsStateManager; -use Helioviewer\Api\Event\EventsTimeline; +use Helioviewer\Api\Event\Timeline\Timeline as EventTimeline; use Helioviewer\Api\Event\Api\EventsApiException; use Helioviewer\Api\Sentry\Sentry; @@ -958,15 +958,14 @@ public function getDataCoverage() { return $this->getDataCoverageForLayers(); } else if (!empty($this->_options['eventLayers'])) { try { - $eventTimeline = new EventsTimeline( + $timeline = new EventTimeline( $this->_options['eventLayers'], $this->_options['startDate'] ?? null, $this->_options['endDate'] ?? null, $this->_options['currentDate'] ?? null, $this->eventsApi() ); - - $this->_printJSON($eventTimeline->timeline()); + $this->_printJSON($timeline->execute()); } catch (InvalidArgumentException $e) { return $this->_sendResponse(400, 'Invalid time parameters', $e->getMessage()); } catch (Exception $e) { diff --git a/tests/unit_tests/events/timeline/ResolutionTest.php b/tests/unit_tests/events/timeline/ResolutionTest.php new file mode 100644 index 000000000..faee7a049 --- /dev/null +++ b/tests/unit_tests/events/timeline/ResolutionTest.php @@ -0,0 +1,41 @@ + + */ + +use PHPUnit\Framework\TestCase; +use Helioviewer\Api\Event\Timeline\Resolution; + +final class ResolutionTest extends TestCase +{ + public static function resolutionProvider(): array + { + return [ + 'zero_range' => [0, 'm'], + 'one_second' => [1000, 'm'], + 'one_hour' => [3600000, 'm'], + 'just_under_1_day' => [86400000 - 1, 'm'], + 'exactly_1_day' => [86400000, '30m'], + 'just_under_2_days' => [172800000 - 1, '30m'], + 'exactly_2_days' => [172800000, 'h'], + 'just_under_10_days' => [864000000 - 1, 'h'], + 'exactly_10_days' => [864000000, 'D'], + 'just_under_6_months' => [16070400000 - 1, 'D'], + 'exactly_6_months' => [16070400000, 'W'], + 'just_under_15_months' => [40176000000 - 1, 'W'], + 'exactly_15_months' => [40176000000, 'M'], + 'just_under_5_years' => [157680000000 - 1, 'M'], + 'exactly_5_years' => [157680000000, 'Y'], + 'ten_years' => [315360000000, 'Y'], + ]; + } + + /** + * @dataProvider resolutionProvider + */ + public function testItShouldReturnCorrectResolution(int $rangeMs, string $expected): void + { + $this->assertEquals($expected, Resolution::fromRange($rangeMs)); + } +} diff --git a/tests/unit_tests/events/timeline/SwimLanerTest.php b/tests/unit_tests/events/timeline/SwimLanerTest.php new file mode 100644 index 000000000..28107b530 --- /dev/null +++ b/tests/unit_tests/events/timeline/SwimLanerTest.php @@ -0,0 +1,178 @@ + + */ + +use PHPUnit\Framework\TestCase; +use Helioviewer\Api\Event\Timeline\SwimLaner; + +final class SwimLanerTest extends TestCase +{ + private SwimLaner $swimLaner; + + protected function setUp(): void + { + $this->swimLaner = new SwimLaner(); + } + + public function testItShouldReturnEmptyForEmptyInput(): void + { + $this->assertEquals([], $this->swimLaner->assign([])); + } + + public function testItShouldAssignSameLaneToNonOverlappingEvents(): void + { + $series = [ + 'AR' => [ + 'data' => [ + ['x' => 100, 'x2' => 200, 'label' => 'e1'], + ['x' => 300, 'x2' => 400, 'label' => 'e2'], + ['x' => 500, 'x2' => 600, 'label' => 'e3'], + ] + ] + ]; + + $result = $this->swimLaner->assign($series); + $data = $result['AR']['data']; + + // All in the same lane since they don't overlap + $this->assertEquals($data[0]['y'], $data[1]['y']); + $this->assertEquals($data[1]['y'], $data[2]['y']); + } + + public function testItShouldAssignDifferentLanesToOverlappingEvents(): void + { + $series = [ + 'AR' => [ + 'data' => [ + ['x' => 100, 'x2' => 300, 'label' => 'e1'], + ['x' => 200, 'x2' => 400, 'label' => 'e2'], + ] + ] + ]; + + $result = $this->swimLaner->assign($series); + $data = $result['AR']['data']; + + // Different lanes since they overlap + $this->assertNotEquals($data[0]['y'], $data[1]['y']); + } + + public function testItShouldReuseLanesForSequentialEvents(): void + { + $series = [ + 'AR' => [ + 'data' => [ + ['x' => 100, 'x2' => 200, 'label' => 'e1'], + ['x' => 150, 'x2' => 250, 'label' => 'e2'], // overlaps e1 + ['x' => 200, 'x2' => 300, 'label' => 'e3'], // fits after e1 + ] + ] + ]; + + $result = $this->swimLaner->assign($series); + $data = $result['AR']['data']; + + // e1 and e2 overlap → different lanes + $this->assertNotEquals($data[0]['y'], $data[1]['y']); + // e3 starts where e1 ends → reuses e1's lane + $this->assertEquals($data[0]['y'], $data[2]['y']); + } + + public function testItShouldHandleSingleEvent(): void + { + $series = [ + 'FL' => [ + 'data' => [ + ['x' => 100, 'x2' => 200, 'label' => 'e1'], + ] + ] + ]; + + $result = $this->swimLaner->assign($series); + $data = $result['FL']['data']; + + $this->assertCount(1, $data); + $this->assertArrayHasKey('y', $data[0]); + } + + public function testItShouldHandleMultipleEventTypes(): void + { + $series = [ + 'AR' => [ + 'data' => [ + ['x' => 100, 'x2' => 300, 'label' => 'ar1'], + ] + ], + 'FL' => [ + 'data' => [ + ['x' => 100, 'x2' => 300, 'label' => 'fl1'], + ] + ] + ]; + + $result = $this->swimLaner->assign($series); + + // Both get y values + $this->assertArrayHasKey('y', $result['AR']['data'][0]); + $this->assertArrayHasKey('y', $result['FL']['data'][0]); + } + + public function testItShouldStackThreeOverlappingEvents(): void + { + $series = [ + 'AR' => [ + 'data' => [ + ['x' => 100, 'x2' => 500, 'label' => 'e1'], + ['x' => 200, 'x2' => 500, 'label' => 'e2'], + ['x' => 300, 'x2' => 500, 'label' => 'e3'], + ] + ] + ]; + + $result = $this->swimLaner->assign($series); + $data = $result['AR']['data']; + + // All three overlap → all different lanes + $lanes = [$data[0]['y'], $data[1]['y'], $data[2]['y']]; + $this->assertCount(3, array_unique($lanes)); + } + + public function testItShouldHandleExactBoundaryTouch(): void + { + // e2 starts exactly when e1 ends — should fit same lane + $series = [ + 'AR' => [ + 'data' => [ + ['x' => 100, 'x2' => 200, 'label' => 'e1'], + ['x' => 200, 'x2' => 300, 'label' => 'e2'], + ] + ] + ]; + + $result = $this->swimLaner->assign($series); + $data = $result['AR']['data']; + + // x >= x2 means fits → same lane + $this->assertEquals($data[0]['y'], $data[1]['y']); + } + + public function testItShouldPreserveOtherEventFields(): void + { + $series = [ + 'AR' => [ + 'data' => [ + ['x' => 100, 'x2' => 200, 'label' => 'test', 'foo' => 'bar'], + ] + ] + ]; + + $result = $this->swimLaner->assign($series); + $event = $result['AR']['data'][0]; + + $this->assertEquals('test', $event['label']); + $this->assertEquals('bar', $event['foo']); + $this->assertArrayHasKey('y', $event); + } +} diff --git a/tests/unit_tests/events/timeline/TimeRangeTest.php b/tests/unit_tests/events/timeline/TimeRangeTest.php new file mode 100644 index 000000000..737c7a695 --- /dev/null +++ b/tests/unit_tests/events/timeline/TimeRangeTest.php @@ -0,0 +1,89 @@ + + */ + +use PHPUnit\Framework\TestCase; +use Helioviewer\Api\Event\Timeline\TimeRange; + +final class TimeRangeTest extends TestCase +{ + public static function invalidTimestampProvider(): array + { + return [ + 'null_start' => [null, 2000, 1500], + 'null_end' => [1000, null, 1500], + 'null_current' => [1000, 2000, null], + 'string_start' => ['abc', 2000, 1500], + 'string_end' => [1000, 'abc', 1500], + 'string_current' => [1000, 2000, 'abc'], + 'float_start' => [10.5, 2000, 1500], + 'start_equals_end' => [1000, 1000, 1000], + 'start_greater_than_end' => [2000, 1000, 1500], + ]; + } + + /** + * @dataProvider invalidTimestampProvider + */ + public function testItShouldThrowForInvalidTimestamps($start, $end, $current): void + { + $this->expectException(InvalidArgumentException::class); + new TimeRange($start, $end, $current); + } + + public function testItShouldAllowNegativeTimestamps(): void + { + $range = new TimeRange(-2000, -1000, -1500); + $this->assertEquals(-2000, $range->start()); + $this->assertEquals(-1000, $range->end()); + $this->assertEquals(-1500, $range->current()); + } + + public function testItShouldStoreTimestamps(): void + { + $range = new TimeRange(1000, 2000, 1500); + $this->assertEquals(1000, $range->start()); + $this->assertEquals(2000, $range->end()); + $this->assertEquals(1500, $range->current()); + } + + public function testItShouldCalculateRange(): void + { + $range = new TimeRange(1000, 5000, 3000); + $this->assertEquals(4000, $range->range()); + } + + public function testItShouldConvertToSeconds(): void + { + $range = new TimeRange(1500000, 3500000, 2500000); + $this->assertEquals(1500, $range->startSec()); + $this->assertEquals(3500, $range->endSec()); + } + + public function testItShouldCreateExtendedRange(): void + { + $range = new TimeRange(1000, 3000, 2000); + $extended = $range->extended(); + + $this->assertInstanceOf(TimeRange::class, $extended); + $this->assertEquals(-1000, $extended->start()); + $this->assertEquals(5000, $extended->end()); + $this->assertEquals(2000, $extended->current()); + } + + public function testItShouldPreserveCurrentInExtendedRange(): void + { + $range = new TimeRange(10000, 20000, 15000); + $extended = $range->extended(); + $this->assertEquals(15000, $extended->current()); + } + + public function testItShouldHaveTripleWidthInExtendedRange(): void + { + $range = new TimeRange(1000, 5000, 3000); + $extended = $range->extended(); + $this->assertEquals(12000, $extended->range()); + } +} diff --git a/tests/unit_tests/events/timeline/TimelineTest.php b/tests/unit_tests/events/timeline/TimelineTest.php new file mode 100644 index 000000000..774ecab7b --- /dev/null +++ b/tests/unit_tests/events/timeline/TimelineTest.php @@ -0,0 +1,68 @@ + + */ + +use PHPUnit\Framework\TestCase; +use Helioviewer\Api\Event\Timeline\Timeline; +use Helioviewer\Api\Event\Timeline\CoverageInterface; +use Helioviewer\Api\Event\Timeline\TimeRange; +use Helioviewer\Api\Event\Api\EventsApiInterface; + +final class TimelineTest extends TestCase +{ + private $mockEventsApi; + private $mockStrategy; + + protected function setUp(): void + { + $this->mockEventsApi = $this->createMock(EventsApiInterface::class); + $this->mockStrategy = $this->createMock(CoverageInterface::class); + } + + public function testItShouldPassCorrectParamsToStrategy(): void + { + $this->mockStrategy->expects($this->once()) + ->method('execute') + ->with( + $this->identicalTo($this->mockEventsApi), + // Original: 1000 to 3000, distance = 2000 + // Extended: -1000 to 5000 + $this->callback(function (TimeRange $range) { + return $range->start() === -1000 && $range->end() === 5000; + }), + // [AR,all,1],[C3,all,1] → paths + $this->equalTo(['HEK>>Active Region', 'CCMC>>DONKI']), + // Range 2000ms → minute resolution + $this->equalTo('m') + ) + ->willReturn([]); + + $timeline = new Timeline( + '[AR,all,1],[C3,all,1]', 1000, 3000, 2000, + $this->mockEventsApi, + $this->mockStrategy + ); + + $timeline->execute(); + } + + public function testItShouldReturnJsonEncodedStrategyOutput(): void + { + $strategyOutput = [ + ['data' => [[1000, 5]], 'event_type' => 'AR', 'res' => 'h', 'showInLegend' => true], + ['data' => [[1000, 3]], 'event_type' => 'FL', 'res' => 'h', 'showInLegend' => true], + ]; + + $this->mockStrategy->method('execute')->willReturn($strategyOutput); + + $timeline = new Timeline( + '[AR,all,1]', 1000, 2000, 1500, + $this->mockEventsApi, + $this->mockStrategy + ); + + $this->assertEquals(json_encode($strategyOutput), $timeline->execute()); + } +} From bce148e3ac76147cd01ce0d1e2e546db1f1b792b Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 7 Apr 2026 03:59:36 +0000 Subject: [PATCH 37/49] implement showInLegend functionality --- src/Event/Timeline/AggregatedCoverage.php | 4 +- src/Event/Timeline/MinuteCoverage.php | 23 +++--- src/Event/Timeline/TimeRange.php | 75 ++++++++++--------- src/Event/Timeline/Timeline.php | 3 +- .../events/timeline/TimeRangeTest.php | 26 +++---- .../events/timeline/TimelineTest.php | 8 +- 6 files changed, 71 insertions(+), 68 deletions(-) diff --git a/src/Event/Timeline/AggregatedCoverage.php b/src/Event/Timeline/AggregatedCoverage.php index 04581ce02..76eea9f95 100644 --- a/src/Event/Timeline/AggregatedCoverage.php +++ b/src/Event/Timeline/AggregatedCoverage.php @@ -35,8 +35,8 @@ public function execute(EventsApiInterface $eventsApi, TimeRange $range, array $ // Call the Events API for bucketed counts $response = $eventsApi->getDistributions( $resolution, - $range->startSec(), - $range->endSec(), + $range->extendedStartSec(), + $range->extendedEndSec(), $paths ); diff --git a/src/Event/Timeline/MinuteCoverage.php b/src/Event/Timeline/MinuteCoverage.php index c93bc473b..dc6523e48 100644 --- a/src/Event/Timeline/MinuteCoverage.php +++ b/src/Event/Timeline/MinuteCoverage.php @@ -39,14 +39,10 @@ public function execute(EventsApiInterface $eventsApi, TimeRange $range, array $ return []; } - // Fetch individual events from the Events API - $response = $eventsApi->getEventsInRange($range->startSec(), $range->endSec(), $paths); + // Fetch individual events from the Events API using extended range + $response = $eventsApi->getEventsInRange($range->extendedStartSec(), $range->extendedEndSec(), $paths); $events = $response['events'] ?? []; - // Extended range in milliseconds (for clamping) - $extendedStartMs = $range->start(); - $extendedEndMs = $range->end(); - // Group events by event_type $results = []; foreach ($events as $event) { @@ -58,7 +54,7 @@ public function execute(EventsApiInterface $eventsApi, TimeRange $range, array $ 'data' => [], 'event_type' => $eventType, 'res' => 'm', - 'showInLegend' => true + 'showInLegend' => false ]; } @@ -66,12 +62,17 @@ public function execute(EventsApiInterface $eventsApi, TimeRange $range, array $ $timeStart = strtotime($event['event_starttime']) * 1000; $timeEnd = strtotime($event['event_endtime']) * 1000; + // Show in legend only if event overlaps the visible range + if ($timeEnd >= $range->start() && $timeStart <= $range->end()) { + $results[$eventType]['showInLegend'] = true; + } + // Clamp event times to the extended range - if ($extendedStartMs > $timeStart) { - $timeStart = $extendedStartMs; + if ($range->extendedStart() > $timeStart) { + $timeStart = $range->extendedStart(); } - if ($extendedEndMs < $timeEnd) { - $timeEnd = $extendedEndMs; + if ($range->extendedEnd() < $timeEnd) { + $timeEnd = $range->extendedEnd(); } // Add timeline coordinates to the event diff --git a/src/Event/Timeline/TimeRange.php b/src/Event/Timeline/TimeRange.php index 6bf44c0d7..264233d12 100644 --- a/src/Event/Timeline/TimeRange.php +++ b/src/Event/Timeline/TimeRange.php @@ -6,56 +6,57 @@ /** * Value object for a timeline time range. + * Stores both the visible range and the extended range (1x buffer each side). * All timestamps are in milliseconds. */ class TimeRange { - private int $startMs; - private int $endMs; - private int $currentMs; + private int $start; + private int $end; + private int $current; + private int $extendedStart; + private int $extendedEnd; /** - * @param int $startTimestamp Start time in milliseconds - * @param int $endTimestamp End time in milliseconds - * @param int $currentTimestamp Current observation time in milliseconds - * @throws InvalidArgumentException If timestamps are not integers or start > end + * @param int $start Visible start in milliseconds + * @param int $end Visible end in milliseconds + * @param int $current Current observation time in milliseconds + * @throws InvalidArgumentException If timestamps are not integers or start >= end */ - public function __construct($startTimestamp, $endTimestamp, $currentTimestamp) + public function __construct($start, $end, $current) { - $startTimestamp = filter_var($startTimestamp, FILTER_VALIDATE_INT); - $endTimestamp = filter_var($endTimestamp, FILTER_VALIDATE_INT); - $currentTimestamp = filter_var($currentTimestamp, FILTER_VALIDATE_INT); + $start = filter_var($start, FILTER_VALIDATE_INT); + $end = filter_var($end, FILTER_VALIDATE_INT); + $current = filter_var($current, FILTER_VALIDATE_INT); - if ($startTimestamp === false || $endTimestamp === false || $currentTimestamp === false) { - throw new InvalidArgumentException('startTimestamp, endTimestamp, and currentTimestamp must be integer timestamps in milliseconds'); + if ($start === false || $end === false || $current === false) { + throw new InvalidArgumentException('start, end, and current must be integer timestamps in milliseconds'); } - if ($startTimestamp >= $endTimestamp) { - throw new InvalidArgumentException('startTimestamp must be less than endTimestamp'); + if ($start >= $end) { + throw new InvalidArgumentException('start must be less than end'); } - $this->startMs = $startTimestamp; - $this->endMs = $endTimestamp; - $this->currentMs = $currentTimestamp; - } - - public function start(): int { return $this->startMs; } - public function end(): int { return $this->endMs; } - public function current(): int { return $this->currentMs; } - public function range(): int { return $this->endMs - $this->startMs; } - public function startSec(): int { return intval($this->startMs / 1000); } - public function endSec(): int { return intval($this->endMs / 1000); } + $this->start = $start; + $this->end = $end; + $this->current = $current; - /** - * Returns an extended TimeRange with 1x buffer on each side for smooth scrolling. - */ - public function extended(): self - { - $distance = $this->range(); - return new self( - $this->startMs - $distance, - $this->endMs + $distance, - $this->currentMs - ); + $distance = $end - $start; + $this->extendedStart = $start - $distance; + $this->extendedEnd = $end + $distance; } + + // Visible range + public function start(): int { return $this->start; } + public function end(): int { return $this->end; } + public function current(): int { return $this->current; } + public function range(): int { return $this->end - $this->start; } + public function startSec(): int { return intval($this->start / 1000); } + public function endSec(): int { return intval($this->end / 1000); } + + // Extended range (1x buffer each side) + public function extendedStart(): int { return $this->extendedStart; } + public function extendedEnd(): int { return $this->extendedEnd; } + public function extendedStartSec(): int { return intval($this->extendedStart / 1000); } + public function extendedEndSec(): int { return intval($this->extendedEnd / 1000); } } diff --git a/src/Event/Timeline/Timeline.php b/src/Event/Timeline/Timeline.php index e752e0496..5a2b1e1a9 100644 --- a/src/Event/Timeline/Timeline.php +++ b/src/Event/Timeline/Timeline.php @@ -68,11 +68,10 @@ public function __construct( public function execute(): string { $paths = iterator_to_array($this->eventSelections); - $extendedRange = $this->range->extended(); $data = $this->strategy->execute( $this->eventsApi, - $extendedRange, + $this->range, $paths, $this->resolution ); diff --git a/tests/unit_tests/events/timeline/TimeRangeTest.php b/tests/unit_tests/events/timeline/TimeRangeTest.php index 737c7a695..648625871 100644 --- a/tests/unit_tests/events/timeline/TimeRangeTest.php +++ b/tests/unit_tests/events/timeline/TimeRangeTest.php @@ -62,28 +62,28 @@ public function testItShouldConvertToSeconds(): void $this->assertEquals(3500, $range->endSec()); } - public function testItShouldCreateExtendedRange(): void + public function testItShouldCalculateExtendedRange(): void { + // Range: 1000 to 3000, distance = 2000 + // Extended: 1000-2000 = -1000, 3000+2000 = 5000 $range = new TimeRange(1000, 3000, 2000); - $extended = $range->extended(); - - $this->assertInstanceOf(TimeRange::class, $extended); - $this->assertEquals(-1000, $extended->start()); - $this->assertEquals(5000, $extended->end()); - $this->assertEquals(2000, $extended->current()); + $this->assertEquals(-1000, $range->extendedStart()); + $this->assertEquals(5000, $range->extendedEnd()); } - public function testItShouldPreserveCurrentInExtendedRange(): void + public function testItShouldConvertExtendedToSeconds(): void { $range = new TimeRange(10000, 20000, 15000); - $extended = $range->extended(); - $this->assertEquals(15000, $extended->current()); + // distance = 10000, extended: 0 to 30000 + $this->assertEquals(0, $range->extendedStartSec()); + $this->assertEquals(30, $range->extendedEndSec()); } - public function testItShouldHaveTripleWidthInExtendedRange(): void + public function testExtendedRangeShouldBeTripleWidth(): void { $range = new TimeRange(1000, 5000, 3000); - $extended = $range->extended(); - $this->assertEquals(12000, $extended->range()); + $extendedWidth = $range->extendedEnd() - $range->extendedStart(); + // Original: 4000. Extended: 3x = 12000 + $this->assertEquals(12000, $extendedWidth); } } diff --git a/tests/unit_tests/events/timeline/TimelineTest.php b/tests/unit_tests/events/timeline/TimelineTest.php index 774ecab7b..0d323d273 100644 --- a/tests/unit_tests/events/timeline/TimelineTest.php +++ b/tests/unit_tests/events/timeline/TimelineTest.php @@ -27,10 +27,12 @@ public function testItShouldPassCorrectParamsToStrategy(): void ->method('execute') ->with( $this->identicalTo($this->mockEventsApi), - // Original: 1000 to 3000, distance = 2000 - // Extended: -1000 to 5000 + // Visible: 1000 to 3000, Extended: -1000 to 5000 $this->callback(function (TimeRange $range) { - return $range->start() === -1000 && $range->end() === 5000; + return $range->start() === 1000 + && $range->end() === 3000 + && $range->extendedStart() === -1000 + && $range->extendedEnd() === 5000; }), // [AR,all,1],[C3,all,1] → paths $this->equalTo(['HEK>>Active Region', 'CCMC>>DONKI']), From b99160c9de009b0bc7810c60a96a069176765a8d Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 7 Apr 2026 04:27:05 +0000 Subject: [PATCH 38/49] add timeline coverate strategies tests --- .../timeline/AggregatedCoverageTest.php | 154 +++++++++++ .../events/timeline/MinuteCoverageTest.php | 243 ++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 tests/unit_tests/events/timeline/AggregatedCoverageTest.php create mode 100644 tests/unit_tests/events/timeline/MinuteCoverageTest.php diff --git a/tests/unit_tests/events/timeline/AggregatedCoverageTest.php b/tests/unit_tests/events/timeline/AggregatedCoverageTest.php new file mode 100644 index 000000000..c9a6a0efb --- /dev/null +++ b/tests/unit_tests/events/timeline/AggregatedCoverageTest.php @@ -0,0 +1,154 @@ + + */ + +use PHPUnit\Framework\TestCase; +use Helioviewer\Api\Event\Timeline\AggregatedCoverage; +use Helioviewer\Api\Event\Timeline\TimeRange; +use Helioviewer\Api\Event\Api\EventsApiInterface; + +final class AggregatedCoverageTest extends TestCase +{ + private $mockEventsApi; + private AggregatedCoverage $coverage; + + protected function setUp(): void + { + $this->mockEventsApi = $this->createMock(EventsApiInterface::class); + $this->coverage = new AggregatedCoverage(); + } + + public function testItShouldReturnEmptyForEmptyPaths(): void + { + $this->mockEventsApi->expects($this->never())->method('getDistributions'); + + $result = $this->coverage->execute( + $this->mockEventsApi, + new TimeRange(1000, 2000, 1500), + [], + 'h' + ); + + $this->assertEquals([], $result); + } + + public function testItShouldCallEventsApiWithExtendedRangeAndResolution(): void + { + $range = new TimeRange(5000000, 10000000, 7000000); + $paths = ['HEK>>Active Region', 'CCMC>>DONKI']; + + $this->mockEventsApi->expects($this->once()) + ->method('getDistributions') + ->with( + 'h', + $range->extendedStartSec(), + $range->extendedEndSec(), + $paths + ) + ->willReturn(['event_types' => [], 'buckets' => []]); + + $this->coverage->execute($this->mockEventsApi, $range, $paths, 'h'); + } + + public function testItShouldBuildSeriesFromBuckets(): void + { + $this->mockEventsApi->method('getDistributions')->willReturn([ + 'event_types' => ['AR', 'FL', 'CH'], + 'buckets' => [ + ['start' => 1000, 'counts' => ['AR' => 5, 'FL' => 3, 'CH' => 0]], + ['start' => 2000, 'counts' => ['AR' => 2, 'FL' => 7, 'CH' => 1]], + ['start' => 3000, 'counts' => ['AR' => 0, 'FL' => 0, 'CH' => 12]], + ['start' => 4000, 'counts' => ['AR' => 1, 'FL' => 1, 'CH' => 1]], + ] + ]); + + $result = $this->coverage->execute( + $this->mockEventsApi, + new TimeRange(1000000, 5000000, 3000000), + ['HEK>>Active Region'], + 'h' + ); + + $this->assertCount(3, $result); + + // Find each series by event_type + $byType = []; + foreach ($result as $series) { + $byType[$series['event_type']] = $series; + } + + // AR + $this->assertEquals([ + [1000000, 5], [2000000, 2], [3000000, 0], [4000000, 1] + ], $byType['AR']['data']); + $this->assertEquals('h', $byType['AR']['res']); + $this->assertTrue($byType['AR']['showInLegend']); + + // FL + $this->assertEquals([ + [1000000, 3], [2000000, 7], [3000000, 0], [4000000, 1] + ], $byType['FL']['data']); + + // CH + $this->assertEquals([ + [1000000, 0], [2000000, 1], [3000000, 12], [4000000, 1] + ], $byType['CH']['data']); + } + + public function testItShouldFillMissingEventTypesWithZero(): void + { + $this->mockEventsApi->method('getDistributions')->willReturn([ + 'event_types' => ['AR', 'FL'], + 'buckets' => [ + ['start' => 1000, 'counts' => ['AR' => 5]], + ['start' => 2000, 'counts' => ['FL' => 3]], + ['start' => 3000, 'counts' => []], + ] + ]); + + $result = $this->coverage->execute( + $this->mockEventsApi, + new TimeRange(1000000, 4000000, 2000000), + ['HEK>>Active Region'], + 'D' + ); + + $byType = []; + foreach ($result as $series) { + $byType[$series['event_type']] = $series; + } + + // AR: present in bucket 1, missing in 2 and 3 + $this->assertEquals([ + [1000000, 5], [2000000, 0], [3000000, 0] + ], $byType['AR']['data']); + + // FL: missing in bucket 1 and 3, present in 2 + $this->assertEquals([ + [1000000, 0], [2000000, 3], [3000000, 0] + ], $byType['FL']['data']); + } + + public function testItShouldReturnSortedByEventType(): void + { + $this->mockEventsApi->method('getDistributions')->willReturn([ + 'event_types' => ['FL', 'AR', 'CH'], + 'buckets' => [ + ['start' => 1000, 'counts' => ['FL' => 1, 'AR' => 2, 'CH' => 3]], + ] + ]); + + $result = $this->coverage->execute( + $this->mockEventsApi, + new TimeRange(1000000, 2000000, 1500000), + ['HEK>>Active Region'], + 'W' + ); + + $this->assertEquals('AR', $result[0]['event_type']); + $this->assertEquals('CH', $result[1]['event_type']); + $this->assertEquals('FL', $result[2]['event_type']); + } +} diff --git a/tests/unit_tests/events/timeline/MinuteCoverageTest.php b/tests/unit_tests/events/timeline/MinuteCoverageTest.php new file mode 100644 index 000000000..4c697529d --- /dev/null +++ b/tests/unit_tests/events/timeline/MinuteCoverageTest.php @@ -0,0 +1,243 @@ + + */ + +use PHPUnit\Framework\TestCase; +use Helioviewer\Api\Event\Timeline\MinuteCoverage; +use Helioviewer\Api\Event\Timeline\SwimLaner; +use Helioviewer\Api\Event\Timeline\TimeRange; +use Helioviewer\Api\Event\Api\EventsApiInterface; + +final class MinuteCoverageTest extends TestCase +{ + private $mockEventsApi; + private $mockSwimLaner; + private MinuteCoverage $coverage; + + protected function setUp(): void + { + $this->mockEventsApi = $this->createMock(EventsApiInterface::class); + $this->mockSwimLaner = $this->createMock(SwimLaner::class); + $this->mockSwimLaner->method('assign')->willReturnArgument(0); + $this->coverage = new MinuteCoverage($this->mockSwimLaner); + } + + public function testItShouldReturnEmptyForEmptyPaths(): void + { + $this->mockEventsApi->expects($this->never())->method('getEventsInRange'); + $this->mockSwimLaner->expects($this->never())->method('assign'); + + $result = $this->coverage->execute( + $this->mockEventsApi, + new TimeRange(1000, 2000, 1500), + [], + 'm' + ); + + $this->assertEquals([], $result); + } + + public function testItShouldCallEventsApiWithExtendedRange(): void + { + // visible: 5000-10000, distance=5000, extended: 0-15000 + $range = new TimeRange(5000000, 10000000, 7000000); + + $this->mockEventsApi->expects($this->once()) + ->method('getEventsInRange') + ->with( + $range->extendedStartSec(), + $range->extendedEndSec(), + ['HEK>>Active Region'] + ) + ->willReturn(['events' => []]); + + $this->coverage->execute($this->mockEventsApi, $range, ['HEK>>Active Region'], 'm'); + } + + public function testItShouldGroupEventsByTypeInSwimLaner(): void + { + $this->mockEventsApi->method('getEventsInRange')->willReturn([ + 'events' => [ + ['event_type' => 'AR', 'event_starttime' => '2024-01-15 12:00:00', 'event_endtime' => '2024-01-15 13:00:00'], + ['event_type' => 'FL', 'event_starttime' => '2024-01-15 12:30:00', 'event_endtime' => '2024-01-15 13:30:00'], + ['event_type' => 'AR', 'event_starttime' => '2024-01-15 14:00:00', 'event_endtime' => '2024-01-15 15:00:00'], + ['event_type' => 'AR', 'event_starttime' => '2024-01-15 16:00:00', 'event_endtime' => '2024-01-15 17:00:00'], + ] + ]); + + $this->mockSwimLaner->expects($this->once()) + ->method('assign') + ->with($this->callback(function ($series) { + return isset($series['AR']) + && isset($series['FL']) + && count($series['AR']['data']) === 3 + && count($series['FL']['data']) === 1; + })) + ->willReturnArgument(0); + + $this->coverage->execute( + $this->mockEventsApi, + new TimeRange(1705312800000, 1705330800000, 1705320000000), + ['HEK>>Active Region'], + 'm' + ); + } + + public function testItShouldUseUnkForMissingEventType(): void + { + $this->mockEventsApi->method('getEventsInRange')->willReturn([ + 'events' => [ + ['event_starttime' => '2024-01-15 12:00:00', 'event_endtime' => '2024-01-15 13:00:00'], + ] + ]); + + $this->mockSwimLaner->expects($this->once()) + ->method('assign') + ->with($this->callback(function ($series) { + return isset($series['UNK']) && count($series['UNK']['data']) === 1; + })) + ->willReturnArgument(0); + + $this->coverage->execute( + $this->mockEventsApi, + new TimeRange(1705312800000, 1705330800000, 1705320000000), + ['HEK>>Active Region'], + 'm' + ); + } + + public function testItShouldClampEventsToExtendedRange(): void + { + // visible: 12:00-14:00, distance=2h, extended: 10:00-16:00 + $visStart = strtotime('2024-01-15 12:00:00') * 1000; + $visEnd = strtotime('2024-01-15 14:00:00') * 1000; + $range = new TimeRange($visStart, $visEnd, $visStart); + + $extStart = $range->extendedStart(); + $extEnd = $range->extendedEnd(); + + $this->mockEventsApi->method('getEventsInRange')->willReturn([ + 'events' => [ + // a) extends beyond both sides → clamp both + ['event_type' => 'AR', 'event_starttime' => '2024-01-15 09:00:00', 'event_endtime' => '2024-01-15 17:00:00'], + // b) inside extended range → no clamp + ['event_type' => 'AR', 'event_starttime' => '2024-01-15 11:00:00', 'event_endtime' => '2024-01-15 13:00:00'], + // c) ends beyond extended → clamp x2 only + ['event_type' => 'AR', 'event_starttime' => '2024-01-15 15:30:00', 'event_endtime' => '2024-01-15 17:30:00'], + ] + ]); + + $insideStart = strtotime('2024-01-15 11:00:00') * 1000; + $insideEnd = strtotime('2024-01-15 13:00:00') * 1000; + $cStart = strtotime('2024-01-15 15:30:00') * 1000; + + $this->mockSwimLaner->expects($this->once()) + ->method('assign') + ->with($this->callback(function ($series) use ($extStart, $extEnd, $insideStart, $insideEnd, $cStart) { + $data = $series['AR']['data']; + // a) clamped both sides + $a = $data[0]['x'] === $extStart && $data[0]['x2'] === $extEnd; + // b) no clamp + $b = $data[1]['x'] === $insideStart && $data[1]['x2'] === $insideEnd; + // c) x stays, x2 clamped + $c = $data[2]['x'] === $cStart && $data[2]['x2'] === $extEnd; + return $a && $b && $c; + })) + ->willReturnArgument(0); + + $this->coverage->execute($this->mockEventsApi, $range, ['HEK>>Active Region'], 'm'); + } + + public function testItShouldShowInLegendWhenEventOverlapsVisibleRange(): void + { + // visible: 12:00-14:00 + $visStart = strtotime('2024-01-15 12:00:00') * 1000; + $visEnd = strtotime('2024-01-15 14:00:00') * 1000; + $range = new TimeRange($visStart, $visEnd, $visStart); + + $this->mockEventsApi->method('getEventsInRange')->willReturn([ + 'events' => [ + // overlaps visible + ['event_type' => 'AR', 'event_starttime' => '2024-01-15 13:00:00', 'event_endtime' => '2024-01-15 15:00:00'], + // inside visible + ['event_type' => 'FL', 'event_starttime' => '2024-01-15 12:30:00', 'event_endtime' => '2024-01-15 13:30:00'], + // barely overlaps end of visible + ['event_type' => 'CH', 'event_starttime' => '2024-01-15 13:59:00', 'event_endtime' => '2024-01-15 14:01:00'], + ] + ]); + + $this->mockSwimLaner->expects($this->once()) + ->method('assign') + ->with($this->callback(function ($series) { + return $series['AR']['showInLegend'] === true + && $series['FL']['showInLegend'] === true + && $series['CH']['showInLegend'] === true; + })) + ->willReturnArgument(0); + + $this->coverage->execute($this->mockEventsApi, $range, ['HEK>>Active Region'], 'm'); + } + + public function testItShouldNotShowInLegendWhenEventOnlyInExtendedRange(): void + { + // visible: 12:00-14:00, extended: 10:00-16:00 + $visStart = strtotime('2024-01-15 12:00:00') * 1000; + $visEnd = strtotime('2024-01-15 14:00:00') * 1000; + $range = new TimeRange($visStart, $visEnd, $visStart); + + $this->mockEventsApi->method('getEventsInRange')->willReturn([ + 'events' => [ + // extended only, before visible + ['event_type' => 'AR', 'event_starttime' => '2024-01-15 10:30:00', 'event_endtime' => '2024-01-15 11:30:00'], + // extended only, after visible + ['event_type' => 'FL', 'event_starttime' => '2024-01-15 14:30:00', 'event_endtime' => '2024-01-15 15:30:00'], + // in visible + ['event_type' => 'CH', 'event_starttime' => '2024-01-15 13:00:00', 'event_endtime' => '2024-01-15 13:30:00'], + ] + ]); + + $this->mockSwimLaner->expects($this->once()) + ->method('assign') + ->with($this->callback(function ($series) { + return $series['AR']['showInLegend'] === false + && $series['FL']['showInLegend'] === false + && $series['CH']['showInLegend'] === true; + })) + ->willReturnArgument(0); + + $this->coverage->execute($this->mockEventsApi, $range, ['HEK>>Active Region'], 'm'); + } + + public function testItShouldReturnIndexedArray(): void + { + $this->mockEventsApi->method('getEventsInRange')->willReturn([ + 'events' => [ + ['event_type' => 'AR', 'event_starttime' => '2024-01-15 12:00:00', 'event_endtime' => '2024-01-15 13:00:00'], + ['event_type' => 'FL', 'event_starttime' => '2024-01-15 12:00:00', 'event_endtime' => '2024-01-15 13:00:00'], + ] + ]); + + // SwimLaner returns keyed by type + $this->mockSwimLaner->expects($this->once()) + ->method('assign') + ->willReturn([ + 'AR' => ['data' => [['x' => 1, 'x2' => 2, 'y' => 1]], 'event_type' => 'AR', 'res' => 'm', 'showInLegend' => true], + 'FL' => ['data' => [['x' => 1, 'x2' => 2, 'y' => 2]], 'event_type' => 'FL', 'res' => 'm', 'showInLegend' => true], + ]); + + $result = $this->coverage->execute( + $this->mockEventsApi, + new TimeRange(1705312800000, 1705330800000, 1705320000000), + ['HEK>>Active Region'], + 'm' + ); + + // Should be indexed 0, 1 — not keyed by 'AR', 'FL' + $this->assertArrayHasKey(0, $result); + $this->assertArrayHasKey(1, $result); + $this->assertArrayNotHasKey('AR', $result); + $this->assertArrayNotHasKey('FL', $result); + } +} From a231f61772438a5ea7d3ccd0d22b4e35a7059aa1 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Thu, 23 Apr 2026 00:49:24 +0000 Subject: [PATCH 39/49] replace event region png with polygon with vector footprint, implement correct coloring for polygons per event_type --- .../Composite/HelioviewerCompositeImage.php | 96 ++++++++++++++----- 1 file changed, 70 insertions(+), 26 deletions(-) diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index 04acbc1c3..c3d6c8051 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -25,6 +25,45 @@ class Image_Composite_HelioviewerCompositeImage { + // Event type colors for polygon fill (hex without #, appended with alpha) + private const EVENT_COLORS = [ + 'AR' => 'FF8F97', + 'CME' => 'FFB294', + 'CD' => 'FFD391', + 'CH' => 'FEF38E', + 'CW' => 'E8FF8C', + 'FI' => 'C8FF8D', + 'FE' => 'A3FF8D', + 'FA' => '7BFF8E', + 'FL' => '7AFFAE', + 'LP' => '7CFFC9', + 'OS' => '81FFFC', + 'SS' => '8CE6FF', + 'EF' => '95C6FF', + 'CJ' => '9DA4FF', + 'PG' => 'AB8CFF', + 'OT' => 'CA89FF', + 'SG' => 'E986FF', + 'SP' => 'FF82FF', + 'CR' => 'FF85FF', + 'CC' => 'FF8ACC', + 'ER' => 'FF8DAD', + 'TO' => 'FF8F97', + 'CE' => 'FFB294', + 'C3' => 'FFD391', + 'FP' => 'AB8CFF', + 'F2' => '7AFFAE', + 'BU' => 'E8FF8C', + 'EE' => '95C6FF', + 'PB' => 'FF85FF', + 'PT' => 'C8FF8D', + 'EP' => '81FFFC', + 'IC' => '8CE6FF', + 'SR' => '9DA4FF', + 'HY' => 'CA89FF', + 'NR' => 'FFD391', + ]; + private $_composite; private $_dir; private $_imageLayers; @@ -681,37 +720,42 @@ private function _addEventLayer($imagickImage) { } } - // Now handle the events + // 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. foreach ($events_to_render as $event) { - if ( array_key_exists('hv_poly_width_max_zoom_pixels', $event) ) { - - $width = round($event['hv_poly_width_max_zoom_pixels'] - * ($this->maxPixelScale/$this->roi->imageScale())); - $height = round($event['hv_poly_height_max_zoom_pixels'] - * ($this->maxPixelScale/$this->roi->imageScale())); - - if ( $width >= 1 && $height >= 1 ) { + 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, + ]; + } - $region_polygon = new IMagick(HV_ROOT_DIR.'/'.urldecode($event['hv_poly_url']) ); + // Need at least 3 points to form a polygon + if (count($polyArray) < 3) continue; - $x = (( $event['hv_poly_hpc_x_final'] - - $this->roi->left()) / $this->roi->imageScale()); - $y = (( $event['hv_poly_hpc_y_final'] - - $this->roi->top() ) / $this->roi->imageScale()); + // 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'; - $x = $x - $this->_timeOffsetX; - $y = $y - $this->_timeOffsetY; + $draw = new \ImagickDraw(); + $draw->setStrokeLineJoin(\Imagick::LINEJOIN_ROUND); + $draw->setStrokeColor('#00000088'); + $draw->setStrokeWidth(1.5); + $draw->setStrokeAntialias(true); + $draw->setFillColor('#' . $fillHex . '66'); + $draw->polygon($polyArray); - $region_polygon->resizeImage( - $width, $height, Imagick::FILTER_LANCZOS,1); - $imagickImage->compositeImage( - $region_polygon, IMagick::COMPOSITE_DISSOLVE, $x, $y); - } - } - } - - if ( isset($region_polygon) ) { - $region_polygon->destroy(); + $imagickImage->drawImage($draw); + $draw->destroy(); } // Now lay down the event MARKERS From a29cb2bb31503156da8bf81f232830bb43660b5b Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Thu, 23 Apr 2026 04:18:27 +0000 Subject: [PATCH 40/49] clean old hek adapter codes after new footprint code --- .../Composite/HelioviewerCompositeImage.php | 73 +------------------ 1 file changed, 2 insertions(+), 71 deletions(-) diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index c3d6c8051..abffb3987 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -84,7 +84,6 @@ class Image_Composite_HelioviewerCompositeImage { protected $scaleType; protected $scaleX; protected $scaleY; - protected $maxPixelScale; protected $roi; protected $imageScale; protected bool $grayscale; @@ -180,8 +179,6 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi $this->celestialBodiesLabels = $celestialBodies['labels']; $this->celestialBodiesTrajectories = $celestialBodies['trajectories']; - - $this->maxPixelScale = 0.60511022; // arcseconds per pixel } /** @@ -765,22 +762,8 @@ private function _addEventLayer($imagickImage) { . $event['type'].'.png' ); - if ( array_key_exists('hpc_boundcc', $event) && ($event['hpc_boundcc'] != '')) { - $polygonCenterX = round($event['hv_poly_width_max_zoom_pixels'] * ($this->maxPixelScale/$this->roi->imageScale())) / 2; - $polygonCenterY = round($event['hv_poly_height_max_zoom_pixels'] * ($this->maxPixelScale/$this->roi->imageScale())) / 2; - - $scaledMarkerX = round($event['hv_marker_offset_x'] * ($this->maxPixelScale/$this->roi->imageScale())); - $scaledMarkerY = round($event['hv_marker_offset_y'] * ($this->maxPixelScale/$this->roi->imageScale())); - - $polygonPosX = (( $event['hv_poly_hpc_x_final'] - $this->roi->left()) / $this->roi->imageScale()); - $polygonPosY = (( $event['hv_poly_hpc_y_final'] - $this->roi->top() ) / $this->roi->imageScale()); - - $x = round($polygonPosX + $polygonCenterX + $scaledMarkerX); - $y = round($polygonPosY + $polygonCenterY + $scaledMarkerY); - }else{ - $x = round(( $event['hv_hpc_x'] - $this->roi->left()) / $this->roi->imageScale()); - $y = round((-$event['hv_hpc_y'] - $this->roi->top() ) / $this->roi->imageScale()); - } + $x = round(( $event['hv_hpc_x'] - $this->roi->left()) / $this->roi->imageScale()); + $y = round((-$event['hv_hpc_y'] - $this->roi->top() ) / $this->roi->imageScale()); $x = $x - $this->_timeOffsetX; $y = $y - $this->_timeOffsetY; @@ -790,14 +773,6 @@ private function _addEventLayer($imagickImage) { $x = $x + 11; $y = $y - 24; - /*$x = (( $event['hv_hpc_x_final'] - $this->roi->left()) - / $this->roi->imageScale()) + 11; - $y = ((-$event['hv_hpc_y_final'] - $this->roi->top() ) - / $this->roi->imageScale()) - 24; - - $x = $x - $this->_timeOffsetX; - $y = $y - $this->_timeOffsetY;*/ - $count = 0; foreach( explode("\n", $event['label']) as $value ) { @@ -1552,50 +1527,6 @@ private function _addTimestampWatermark($imagickImage) { $text->destroy(); } - /** - * Sorts the layers by their associated layering order - * - * Layering orders that are supported currently are 3 (C3 images), - * 2 (C2 images), 1 (EIT/MDI images). - * The array is sorted by increasing layeringOrder. - * - * @param array &$images Array of Composite image layers - * - * @return array Array containing the sorted image layers - */ - private function _sortByLayeringOrder(&$images) { - $sortedImages = array(); - - // Array to hold any images with layering order 2 or 3. - // These images must go in the sortedImages array last because of how - // compositing works. - $groups = array('2' => array(), '3' => array()); - - // Push all layering order 1 images into the sortedImages array, - // push layering order 2 and higher into separate array. - foreach ($images as $image) { - $order = $image->getLayeringOrder(); - - if ($order > 1) { - array_push($groups[$order], $image); - } - else { - array_push($sortedImages, $image); - } - } - - // Push the group 2's and group 3's into the sortedImages array now. - foreach ($groups as $group) { - foreach ($group as $image) { - array_push($sortedImages, $image); - } - } - - // return the sorted array in order of smallest layering order to - // largest. - return $sortedImages; - } - private function _addEclipseOverlay(IMagick $image, float $scale, bool $showMoon) { include_once HV_ROOT_DIR . "/../src/Image/EclipseOverlay.php"; // Add extra eclipse content to the image From b8bd12f5288c33adebdb131b80129550e85ea750 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Thu, 23 Apr 2026 04:37:58 +0000 Subject: [PATCH 41/49] performance improvement, instead of creating fresh instances of imagick objects for event_marker pngs, we create only one instance per event_type and reuse it , this prevents creating 40 imagick SS.png objects, for 40 different Sunspots or any other types,,after this improvement, there will only be one imagick instance of this event_type.png per event_type --- .../Composite/HelioviewerCompositeImage.php | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index abffb3987..cabe39861 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -756,10 +756,16 @@ private function _addEventLayer($imagickImage) { } // Now lay down the event MARKERS + // Cache marker images by type — load each PNG once, clone for reuse + $markerCache = []; foreach( $events_to_render as $event ) { - $marker = new IMagick( HV_ROOT_DIR - . '/resources/images/eventMarkers/' - . $event['type'].'.png' ); + $type = $event['type'] ?? 'UNK'; + if (!isset($markerCache[$type])) { + $markerCache[$type] = new IMagick( + HV_ROOT_DIR . '/resources/images/eventMarkers/' . $type . '.png' + ); + } + $marker = clone $markerCache[$type]; $x = round(( $event['hv_hpc_x'] - $this->roi->left()) / $this->roi->imageScale()); @@ -811,8 +817,9 @@ private function _addEventLayer($imagickImage) { } } - if ( isset($marker) ) { - $marker->destroy(); + // Cleanup cached marker images + foreach ($markerCache as $m) { + $m->destroy(); } } From fdc75466acbf1f2be46b27966afcb1abcefe666a Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 28 Apr 2026 21:58:33 +0000 Subject: [PATCH 42/49] make c3 pins same color as cme , more preferable if system has to select --- docroot/resources/images/eventMarkers/C3.png | Bin 630 -> 630 bytes .../resources/images/eventMarkers/C3@2x.png | Bin 1347 -> 1355 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/docroot/resources/images/eventMarkers/C3.png b/docroot/resources/images/eventMarkers/C3.png index 7fb28231e8ec4a547566164e4e7e0c349b08da04..33d2c15fde62d8a16219f06e6c5f055418f92dca 100644 GIT binary patch delta 595 zcmV-Z0<8V^1oi}wE`LZ=Q5462@7Ws+t(i(tB-^yDh>+Suv}sigwF!a=G9?HG35*s) zI)qINNeRXZOff>MT@cjLK!O%gK}AFu*Loo3qfocD3xefN#h$5{>BbAIQ4 z{`X^^hJTbLYnrT3wkU%tu|#kow0wWCQWT9VHE_aRU@KHebALC|TtA#}Kd4FreWhSo zz6lSyd43JbR=BT3>4W!_;Nvrhn?*hc~%2`hx8EW!8oa&ykCz67gHtnRq(GyQ?WX^tbvo&Z5~&o=xFe zDHy;w+)21T#EtX&Jab&Ov^3U{`C1@fSdhXO?_HrQwu9r{kNA|Elf%tX+V&j5Fm&Q$ zLuBUWnaO_RP+uSI!)XB8htupln`HjSA|a!Og{7Y?6@QC3IYQ-4k@ZZbx_J9BM@w6R zZJig;b&X^+20$_zBU~S%`DB8Dv6u9oZe?@B2BC8E`d58BZ7};Ypt+Q6g(KPhzBdEC|GhT6g&vlqKAlq3T+NOC=>*1F{V+fN!!GDkX^R3v$L~l@#j4) z%s0=x-#b5vfxqgCF+(*`HK>AWv1D*5v^06n{%+}hptXPqcYlGMP?;nP8Lkfz?g!Ob zz+5j_Rd2zAZl2!)btl}{MI$nh#PND&fomgEw(5@qnhK6-mPZJ;xMm-7z`OPuI?#@=09n0}IA^6qg8r3%BNc>wN>%`)?@NIuic zv4cCgdNWJiBY#xex#fCzG|$lex6F;7A{q&Kwp{yJr~mdFCUPIiJvzB&%yPd;oHSqf zL0c@U1Ov1kc}T1|!pOiO&l*=OJGM7bSgcU4RF&|;^Rx7wj&o-41)moyYPi)(qNkH^ z*rfkXj^ejvK7A>Z9GD=L&H|81XE}UvobTl-k#LCR)qfhR)fys4>U>-4W}eQen*G{;qFeuoT6R!Np3Lu*!Iu9I=EDkTYYGPo?;M(!1TzkRS6neSW>1w0SxwKmq zTsI!iI!rC+Vg$My{YS39m;5$X8C_uKLC(Goyy0hIJ(>G1s_RPj=fQXGUp+0BM3-6} h{}aytZ+PJI{tN%$rg)4O$rk_s002ovPDHLkV1itw9jpKV diff --git a/docroot/resources/images/eventMarkers/C3@2x.png b/docroot/resources/images/eventMarkers/C3@2x.png index 55d639b85419c17e05a84f78962daeb773f6a84a..5cb8565a437981888ba4cf4c437b63b4cec69e40 100644 GIT binary patch delta 1336 zcmV-81;_fs3d;(RB!3b~L_t(|ob8%_Oq6vT$6qIcC+8XX+fWu05);$@$)+@ytJ#EE z4$WYt*_N5Qr8Zl`bd8i%i_oN@GuN_7q@n|DbFMUNr7gG8*z`<2L|~Aeop=1a6X*}S zlk@rYeeRyS1L6G_d-#67pV#xg_xF3>=K<)WG8r&OcQks3QhyvQuptb=6d474uXM5K z8LSE1T~(sqY{43lK0-{Q;HuDCz>=ogH=xNS03r{brr1RgPHfw^T_)EpO`*f8noAqOtG!$?4D^pY^$3m#Qyjh+E)!cpmM z>IvQ}+}7yrXMZ%wT~sNgwY`HKKYqivKi6`x#);GI!8{~}w22Rpm6^)gW{)@N2Cu$WPuA8Q)HXKDHUXG0*21w( zYnhrnKH&P%D@VzB`#2tNm!u|UkcoX;=CUMXp)j~FDu0^L4tERl3O@Biznqmub;(9r z4;FKw^j%iwOu=M=x~3N9Z`n?p$Lp_c_j-A?;0HY3F07VVjubyj^Zp`A-^m1^v#XoU zJI~4*A;Z$$6I@dt9NS03jTQiwFPO~U*WYAn@_2^E#gd*hj?!hTS(`fzfZE1pKL7QA zzt&dQgnuay&bsov2M*- zCOf9!xn)$IDj;*lI364Mxc~a}lnGoZEtIWk>$n3uD#1)_j4+NATrMWg=2mJN+yEqv zu`oVyxa?%ZhME-t<>vh4L^+qQy8u|V_z}gi-G5ajlB^5e;tki`#rWW8ix8NadO}Qp zcqFeqpU&#cTwQYrWjw}ock}7&l3!b#@-xoc zm4C+oSmVvSG$V&KFJu6BoUgikRFqqMJItGx>i>E3p|54fY(G`TFXx)rS#g1);}!nv zdw$zb>Z*Ja^43%GQ($dp*WIrWr`r=exYA?P`nh?eKa>E#;gdBi+E&CLSLq{FD$0|%1znd`=GyjZyk@OBn4bwSH6pFWq&HxtynmPUCURCf}3=K z19&@cv-`{eDoxG8L?;;0wg5Z8@}Sul==W1;Gvvo}RWmx2Wk za7fQbuk`S%SN+a$i9}pCa_8vwa@H{lTrn=;df*s{N~C2ZxJY&lJIfpWP@wgX!-n-6h)SeoG`Lz63^9%6fa8(Y u0C=P|0Ing!-+qMyJR*Nu@3T?qv-%Gxb(eq)a1E{i0000Bn zMcN3B5?XCAwl=-ehuX+nV|-B3hZ@tQHZ@g@HdUlhOHFL3klLnItD#;%5J9XIOSz-6 zz(OAgE4w?hb7psTm&<-no7wX}|KEJ`otZNWFiWM=V2%lI^nZ4x*jZpr7@R563fNYv zSkw$w1n#RXa`=73vj;@_2r-F*JA_sOww1sMC~}E_%R;LKww1yOIAl`-mxOi|*j5fs zK$g$X!F(QpR+v-vYRiFp3|e4rNft+ri%zVW)bx-7w>v{mKr8gJW3mPJDzrjRD>mV% z%rs30pAl{=^nbQ6>ZA@jDCGX|7zcl7qWaf%8r%Bl8yq2Mfj^1M!dbgAmbda#2o2Fo z)}C+ZV$V01sc-70v-=(k4S{5(Euy$EohMVHC2J(^@jBJu4XwQtl+@7PIV9NxVCiB5 z<-1pr92a4A{FjTJyz=oWMn=c&YW)3l9NzgnYo1!B8h>07x_7`t_LlGI=(-EQrnQN@ zyFQh;C1KpUGsOPNM)p-SFfjqKF`=9-%Ofbz9{|&+nY0bZ=;|FN%%JDU&KJl`iK42u zjkkB50U#+hoU>(ll4eN33op*kk6p1qZ+>|Nd$(ni92Y@Q;ABj8y`RO0h3NpacMfs% zrxr_XbANk3VIhG;gauNRyMm{mh#@2>fWkEiq$d~wXln0g%xr%(&u z@bDr7i;cmOlNo~bvVc-^epT7X^;-h~ytDpM*?+NpwMBMu5puk{f7tSQNwh%-%#o%; zJf8FrZ>~#bV@{%~IfSl_pYFlU$E+fUB5Lax{G7HZk1a1h{lk!L;0Js0WOG9g~?AV;*lWgDn2Ul9; z$A18XhUj_endQ8*md~YS^^#)_oM_^=`a67ayn%fc4VL5O zH8;rISi$n#BOI!3wyH6YKX`=n4URYuF83Ps#=2z7)9|~~ZEV?5$EE8%m`2TX^$xT9 zU_IOR{0>Y;t7LPAr8X_z$c@_r^bd_tT7Op0xvO0ao6MZM+Qru=u35%qrbJ2hmSPfq za>8Ux3QB6Y+|ncY{SjYgkF+}e$(Mif*^x`KbzAdNC@szs1=pzp2N*Mt^X;h? zs_Jg?S93Q#119tV{=_d0BPTnSt@$Y!7cP*jtv=Jv{;EbUUh8IHXoS!ZJ!$bqUVqO` zq+nINY)?nvs^C2-xlPubc#*J@;Ll}ll_Q4f;9C6T>~2{Y9Y)2Qt}FGn1-EnE8}Kqc zt=Q(&;`FNPB5s4c<6eOmg@!p6l_`1Dbv15-vg00sR|^euG%8batLx6V4dRZwWjo+3 zG`J8IZ*RiXfNL5VSKNk~wK!G9QGYGCCa!A{GiMxk$3j&-=S+%o<&c_7FZc4R zR`bqrJBhfi<-Ok(a4i<8({s$*s0c4otrq2!=B?vGa>s^D3vg%d=`Js9UsO~`lf8~s z;IeUX*9(U)DxQ{>;3C=8Y%j0%OM%iq4r|uW7Zp!SYj8(V&@`4l$2~0{xGAUQ16Ppd mzkay`+#`QlpS4k$wfZ0Gh?TroO86H50000 Date: Tue, 28 Apr 2026 22:51:42 +0000 Subject: [PATCH 43/49] if cant pin.png for any reason, we fallback to UNK.png to safe guard, also implement tests for this --- .../Composite/HelioviewerCompositeImage.php | 27 ++++++-- .../image/HelioviewerCompositeImageTest.php | 65 +++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 tests/unit_tests/image/HelioviewerCompositeImageTest.php diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index cabe39861..bd44dd851 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -64,6 +64,21 @@ class Image_Composite_HelioviewerCompositeImage { 'NR' => 'FFD391', ]; + /** + * Resolve an event marker PNG path, falling back to UNK.png when the + * type-specific file does not exist. Mirrors the polygon path's + * EVENT_COLORS fallback behavior so an unknown event type renders a + * generic marker instead of throwing an Imagick exception. + */ + public static function resolveMarkerPath(string $baseDir, string $type): string + { + $path = $baseDir . '/' . $type . '.png'; + if (!file_exists($path)) { + return $baseDir . '/UNK.png'; + } + return $path; + } + private $_composite; private $_dir; private $_imageLayers; @@ -756,16 +771,16 @@ private function _addEventLayer($imagickImage) { } // Now lay down the event MARKERS - // Cache marker images by type — load each PNG once, clone for reuse + // Cache marker images by resolved path — multiple unknown types share one UNK.png load $markerCache = []; + $markerDir = HV_ROOT_DIR . '/resources/images/eventMarkers'; foreach( $events_to_render as $event ) { $type = $event['type'] ?? 'UNK'; - if (!isset($markerCache[$type])) { - $markerCache[$type] = new IMagick( - HV_ROOT_DIR . '/resources/images/eventMarkers/' . $type . '.png' - ); + $path = self::resolveMarkerPath($markerDir, $type); + if (!isset($markerCache[$path])) { + $markerCache[$path] = new IMagick($path); } - $marker = clone $markerCache[$type]; + $marker = clone $markerCache[$path]; $x = round(( $event['hv_hpc_x'] - $this->roi->left()) / $this->roi->imageScale()); diff --git a/tests/unit_tests/image/HelioviewerCompositeImageTest.php b/tests/unit_tests/image/HelioviewerCompositeImageTest.php new file mode 100644 index 000000000..b84e97d6c --- /dev/null +++ b/tests/unit_tests/image/HelioviewerCompositeImageTest.php @@ -0,0 +1,65 @@ +tmpDir = sys_get_temp_dir() . '/' . uniqid('mtest_', true); + mkdir($this->tmpDir); + // Placeholder files; resolveMarkerPath only checks existence, not contents. + file_put_contents($this->tmpDir . '/FOO.png', ''); + file_put_contents($this->tmpDir . '/UNK.png', ''); + } + + protected function tearDown(): void + { + foreach (glob($this->tmpDir . '/*') ?: [] as $f) { + unlink($f); + } + rmdir($this->tmpDir); + } + + public function testItShouldReturnTypeSpecificPathWhenFileExists(): void + { + $this->assertEquals( + $this->tmpDir . '/FOO.png', + Image_Composite_HelioviewerCompositeImage::resolveMarkerPath($this->tmpDir, 'FOO') + ); + } + + public function testItShouldFallBackToUNKWhenTypeFileMissing(): void + { + $this->assertEquals( + $this->tmpDir . '/UNK.png', + Image_Composite_HelioviewerCompositeImage::resolveMarkerPath($this->tmpDir, 'BAR') + ); + } + + public function testItShouldReturnUNKPathWhenTypeIsExplicitlyUNK(): void + { + $this->assertEquals( + $this->tmpDir . '/UNK.png', + Image_Composite_HelioviewerCompositeImage::resolveMarkerPath($this->tmpDir, 'UNK') + ); + } + + public function testItShouldStillReturnUNKPathEvenWhenUNKFileItselfMissing(): void + { + $emptyDir = sys_get_temp_dir() . '/' . uniqid('mtest_empty_', true); + mkdir($emptyDir); + try { + $this->assertEquals( + $emptyDir . '/UNK.png', + Image_Composite_HelioviewerCompositeImage::resolveMarkerPath($emptyDir, 'BAZ') + ); + } finally { + rmdir($emptyDir); + } + } +} From 050419ec766f1ebde3ca19c84e1cabaccf72cc9c Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Wed, 29 Apr 2026 19:36:52 +0000 Subject: [PATCH 44/49] add strict_type=1 for extra safety --- src/Event/Api/EventsApi.php | 2 +- src/Event/Api/EventsApiException.php | 2 +- src/Event/Api/EventsApiInterface.php | 2 +- src/Event/Api/LegacyEvents.php | 2 +- src/Event/Api/LegacyEventsInterface.php | 2 +- src/Event/EventSelections.php | 2 +- src/Event/Timeline/AggregatedCoverage.php | 2 +- src/Event/Timeline/CoverageInterface.php | 2 +- src/Event/Timeline/MinuteCoverage.php | 2 +- src/Event/Timeline/Resolution.php | 2 +- src/Event/Timeline/SwimLaner.php | 2 +- src/Event/Timeline/TimeRange.php | 2 +- src/Event/Timeline/Timeline.php | 2 +- src/Module/AbstractModule.php | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index 85ea83ae7..8b1c14f47 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -1,4 +1,4 @@ - Date: Wed, 29 Apr 2026 19:56:54 +0000 Subject: [PATCH 45/49] AbstractModule to BaseModule --- src/Module/{AbstractModule.php => BaseModule.php} | 4 ++-- src/Module/JHelioviewer.php | 4 ++-- src/Module/Movies.php | 4 ++-- src/Module/SolarBodies.php | 4 ++-- src/Module/SolarEvents.php | 4 ++-- src/Module/WebClient.php | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) rename src/Module/{AbstractModule.php => BaseModule.php} (96%) diff --git a/src/Module/AbstractModule.php b/src/Module/BaseModule.php similarity index 96% rename from src/Module/AbstractModule.php rename to src/Module/BaseModule.php index 207cb6d12..ee31f73c5 100644 --- a/src/Module/AbstractModule.php +++ b/src/Module/BaseModule.php @@ -1,6 +1,6 @@ Date: Wed, 29 Apr 2026 21:44:00 +0000 Subject: [PATCH 46/49] use Throwable instead Exception for broader error handling --- src/Event/Api/EventsApi.php | 8 ++++---- src/Image/Composite/HelioviewerCompositeImage.php | 2 +- src/Module/SolarEvents.php | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index 8b1c14f47..c3254bfa8 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -86,7 +86,7 @@ public function getEventsForSourceLegacy(DateTimeInterface $observationTime, str try { $response = $this->client->request('GET', $url); return $this->parseResponse($response); - } catch (\Exception $e) { + } catch (\Throwable $e) { $this->sentry->setContext('EventsApi', [ 'error' => $e->getMessage(), ]); @@ -112,7 +112,7 @@ public function getEventsInRange(int $fromTimestamp, int $toTimestamp, array $pa ]); return $this->parseResponse($response); - } catch (\Exception $e) { + } catch (\Throwable $e) { $this->sentry->setContext('EventsApi', [ 'error' => $e->getMessage(), ]); @@ -138,7 +138,7 @@ public function getDistributions(string $size, int $fromTimestamp, int $toTimest ]); return $this->parseResponse($response); - } catch (\Exception $e) { + } catch (\Throwable $e) { $this->sentry->setContext('EventsApi', [ 'error' => $e->getMessage(), ]); @@ -176,7 +176,7 @@ public function getEventsBatch(array $timestamps, array $sources): array 'json' => ['timestamps' => $chunkTimestamps] ]); return $this->parseResponse($response); - } catch (\Exception $e) { + } catch (\Throwable $e) { $this->sentry->setContext('EventsApi', [ 'error' => $e->getMessage(), ]); diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index bd44dd851..2f6379af5 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -653,7 +653,7 @@ private function _addEventLayer($imagickImage) { ); } catch (EventsApiException $e) { // Already captured to Sentry by EventsApi - } catch (\Exception $e) { + } catch (\Throwable $e) { Sentry::capture($e); } } diff --git a/src/Module/SolarEvents.php b/src/Module/SolarEvents.php index 7d7f41c78..e22dc7ec0 100644 --- a/src/Module/SolarEvents.php +++ b/src/Module/SolarEvents.php @@ -210,7 +210,7 @@ public function events() { $data = array_merge($data, $sourceData); } catch (EventsApiException $e) { return $this->_sendResponse(500, 'Internal Server Error', 'Failed to fetch events from ' . $source); - } catch (\Exception $e) { + } catch (\Throwable $e) { Sentry::capture($e); return $this->_sendResponse(500, 'Internal Server Error', 'Failed to fetch events from ' . $source); } From 265e9f58a7cb69a5f154844d2f55fcb293a8a909 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Thu, 30 Apr 2026 18:10:38 +0000 Subject: [PATCH 47/49] use floatval in config --- src/Config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config.php b/src/Config.php index 8b7c2207c..abc9b6215 100644 --- a/src/Config.php +++ b/src/Config.php @@ -92,7 +92,7 @@ private function _fixTypes() { // floats foreach ($this->_floats as $float) { if (isset($this->config[$float])) { - $this->config[$float] = (float)$this->config[$float]; + $this->config[$float] = floatval($this->config[$float]); } } } From 5bc2e45e2e19ea51d06adaee25ce9d366eb3ffd0 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 4 May 2026 21:39:19 +0000 Subject: [PATCH 48/49] align with events-api database in terms of pins to selection paths translation --- src/Event/EventSelections.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Event/EventSelections.php b/src/Event/EventSelections.php index ba32cfd7b..abbf17753 100644 --- a/src/Event/EventSelections.php +++ b/src/Event/EventSelections.php @@ -47,9 +47,9 @@ class EventSelections implements ArrayAccess, Countable, IteratorAggregate 'TO' => 'Topological Object', 'HY' => 'Hypothesis', 'BU' => 'UVBurst', - 'EE' => 'ExplosiveEvent', - 'PB' => 'ProminenceBubble', - 'PT' => 'PeacockTail', + 'EE' => 'Explosive Event', + 'PB' => 'Prominence Bubble', + 'PT' => 'Peacock Tail', 'EP' => 'SEPs', 'IC' => 'ICMEs', 'SR' => 'SIRs', From 7182ebfe9b68939eaa4d2ae16997848f98ccaa9e Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 11 May 2026 19:46:11 +0000 Subject: [PATCH 49/49] edit events_api client to use hv_proxy --- src/Event/Api/EventsApi.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index c3254bfa8..ae50b2f4c 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -52,7 +52,8 @@ public function __construct(ClientInterface $client = null, SentryClientInterfac $timeout = defined('HV_EVENTS_API_TIMEOUT') ? HV_EVENTS_API_TIMEOUT : 10; $connectTimeout = 2; $baseUrl = defined('HV_EVENTS_API_URL') ? HV_EVENTS_API_URL : 'https://events.helioviewer.org'; - $this->client = $client ?? new Client([ + + $options = [ 'base_uri' => $baseUrl, 'timeout' => $timeout, 'connect_timeout' => $connectTimeout, @@ -60,7 +61,12 @@ public function __construct(ClientInterface $client = null, SentryClientInterfac 'Accept' => 'application/json', 'User-Agent' => 'Helioviewer-API/2.0' ] - ]); + ]; + if (defined('HV_PROXY_HOST')) { + $options['proxy'] = HV_PROXY_HOST; + } + + $this->client = $client ?? new Client($options); $this->sentry = $sentry ?? Sentry::$client; $this->legacyEvents = $legacyEvents ?? new LegacyEvents(); @@ -68,6 +74,7 @@ public function __construct(ClientInterface $client = null, SentryClientInterfac 'api_url' => $baseUrl, 'timeout' => $timeout, 'connect_timeout' => $connectTimeout, + 'proxy' => defined('HV_PROXY_HOST') ? HV_PROXY_HOST : null, ]); }