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 +}]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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); +?> + + + diff --git a/docroot/resources/images/eventMarkers/BU.png b/docroot/resources/images/eventMarkers/BU.png new file mode 100644 index 000000000..df8a2264d Binary files /dev/null and b/docroot/resources/images/eventMarkers/BU.png differ diff --git a/docroot/resources/images/eventMarkers/BU@2x.png b/docroot/resources/images/eventMarkers/BU@2x.png new file mode 100644 index 000000000..a1e90c683 Binary files /dev/null and b/docroot/resources/images/eventMarkers/BU@2x.png differ diff --git a/docroot/resources/images/eventMarkers/C3.png b/docroot/resources/images/eventMarkers/C3.png new file mode 100644 index 000000000..33d2c15fd Binary files /dev/null and b/docroot/resources/images/eventMarkers/C3.png differ diff --git a/docroot/resources/images/eventMarkers/C3@2x.png b/docroot/resources/images/eventMarkers/C3@2x.png new file mode 100644 index 000000000..5cb8565a4 Binary files /dev/null and b/docroot/resources/images/eventMarkers/C3@2x.png differ diff --git a/docroot/resources/images/eventMarkers/EE.png b/docroot/resources/images/eventMarkers/EE.png new file mode 100644 index 000000000..efff14a44 Binary files /dev/null and b/docroot/resources/images/eventMarkers/EE.png differ diff --git a/docroot/resources/images/eventMarkers/EE@2x.png b/docroot/resources/images/eventMarkers/EE@2x.png new file mode 100644 index 000000000..ee257ad67 Binary files /dev/null and b/docroot/resources/images/eventMarkers/EE@2x.png differ diff --git a/docroot/resources/images/eventMarkers/EP.png b/docroot/resources/images/eventMarkers/EP.png new file mode 100644 index 000000000..90901a813 Binary files /dev/null and b/docroot/resources/images/eventMarkers/EP.png differ diff --git a/docroot/resources/images/eventMarkers/EP@2x.png b/docroot/resources/images/eventMarkers/EP@2x.png new file mode 100644 index 000000000..42bde750d Binary files /dev/null and b/docroot/resources/images/eventMarkers/EP@2x.png differ diff --git a/docroot/resources/images/eventMarkers/F2.png b/docroot/resources/images/eventMarkers/F2.png new file mode 100644 index 000000000..cfd3ce0ce Binary files /dev/null and b/docroot/resources/images/eventMarkers/F2.png differ diff --git a/docroot/resources/images/eventMarkers/F2@2x.png b/docroot/resources/images/eventMarkers/F2@2x.png new file mode 100644 index 000000000..d151007d4 Binary files /dev/null and b/docroot/resources/images/eventMarkers/F2@2x.png differ diff --git a/docroot/resources/images/eventMarkers/HY.png b/docroot/resources/images/eventMarkers/HY.png new file mode 100644 index 000000000..a9a54647f Binary files /dev/null and b/docroot/resources/images/eventMarkers/HY.png differ diff --git a/docroot/resources/images/eventMarkers/HY@2x.png b/docroot/resources/images/eventMarkers/HY@2x.png new file mode 100644 index 000000000..b8d95ad51 Binary files /dev/null and b/docroot/resources/images/eventMarkers/HY@2x.png differ diff --git a/docroot/resources/images/eventMarkers/IC.png b/docroot/resources/images/eventMarkers/IC.png new file mode 100644 index 000000000..03810830c Binary files /dev/null and b/docroot/resources/images/eventMarkers/IC.png differ diff --git a/docroot/resources/images/eventMarkers/IC@2x.png b/docroot/resources/images/eventMarkers/IC@2x.png new file mode 100644 index 000000000..395b3355b Binary files /dev/null and b/docroot/resources/images/eventMarkers/IC@2x.png differ diff --git a/docroot/resources/images/eventMarkers/NR.png b/docroot/resources/images/eventMarkers/NR.png new file mode 100644 index 000000000..fe74e35ad Binary files /dev/null and b/docroot/resources/images/eventMarkers/NR.png differ diff --git a/docroot/resources/images/eventMarkers/NR@2x.png b/docroot/resources/images/eventMarkers/NR@2x.png new file mode 100644 index 000000000..779a1f18c Binary files /dev/null and b/docroot/resources/images/eventMarkers/NR@2x.png differ diff --git a/docroot/resources/images/eventMarkers/OT.png b/docroot/resources/images/eventMarkers/OT.png new file mode 100644 index 000000000..2a3e72d14 Binary files /dev/null and b/docroot/resources/images/eventMarkers/OT.png differ diff --git a/docroot/resources/images/eventMarkers/OT@2x.png b/docroot/resources/images/eventMarkers/OT@2x.png new file mode 100644 index 000000000..3d1993aca Binary files /dev/null and b/docroot/resources/images/eventMarkers/OT@2x.png differ diff --git a/docroot/resources/images/eventMarkers/PB.png b/docroot/resources/images/eventMarkers/PB.png new file mode 100644 index 000000000..328f16064 Binary files /dev/null and b/docroot/resources/images/eventMarkers/PB.png differ diff --git a/docroot/resources/images/eventMarkers/PB@2x.png b/docroot/resources/images/eventMarkers/PB@2x.png new file mode 100644 index 000000000..91dcda5ec Binary files /dev/null and b/docroot/resources/images/eventMarkers/PB@2x.png differ diff --git a/docroot/resources/images/eventMarkers/PT.png b/docroot/resources/images/eventMarkers/PT.png new file mode 100644 index 000000000..356974f10 Binary files /dev/null and b/docroot/resources/images/eventMarkers/PT.png differ diff --git a/docroot/resources/images/eventMarkers/PT@2x.png b/docroot/resources/images/eventMarkers/PT@2x.png new file mode 100644 index 000000000..5a0791646 Binary files /dev/null and b/docroot/resources/images/eventMarkers/PT@2x.png differ diff --git a/docroot/resources/images/eventMarkers/SR.png b/docroot/resources/images/eventMarkers/SR.png new file mode 100644 index 000000000..0553fd397 Binary files /dev/null and b/docroot/resources/images/eventMarkers/SR.png differ diff --git a/docroot/resources/images/eventMarkers/SR@2x.png b/docroot/resources/images/eventMarkers/SR@2x.png new file mode 100644 index 000000000..95b2ad809 Binary files /dev/null and b/docroot/resources/images/eventMarkers/SR@2x.png differ diff --git a/settings/Config.Example.ini b/settings/Config.Example.ini index 6d943c986..d9f907838 100644 --- a/settings/Config.Example.ini +++ b/settings/Config.Example.ini @@ -85,6 +85,12 @@ 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" + +; 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 16e46f457..abc9b6215 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; /** @@ -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']); @@ -83,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] = floatval($this->config[$float]); + } } } 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 7356a577d..db63eb75a 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 @@ -99,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 */ @@ -1319,437 +1261,6 @@ public function getDataCoverage($layers, $resolution, $startDate, $endDate) { } - /** - * Gets latest datasource coverage and return as JSON - */ - 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 - - $distance = $endDate->getTimestamp() - $startDate->getTimestamp(); - $interval = new DateInterval('PT'.$distance.'S'); - - $visibleStartTimestamp = $startDate->getTimestamp(); - $visibleEndTimestamp = $endDate->getTimestamp(); - - $startDate->modify('-'.$distance.' seconds'); - $endDate->modify('+'.$distance.' seconds'); - - $dateStart = toMySQLDateString($startDate); - $dateEnd = toMySQLDateString($endDate); - - $startTimestamp = $startDate->getTimestamp(); - $endTimestamp = $endDate->getTimestamp(); - $currentTimestamp = $currentDate->getTimestamp(); - - $sources = array(); - - if(!$events){ - return json_encode(array()); - } - - $eventsKeys = self::EVENT_KEYS; - - $eventsColors = self::EVENT_COLORS; - - $dbData = array(); - $dbVisibleData = array(); - $layersString = ''; - foreach($events->toArray() as $layer){ - - 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); - $layersString .= '(event_type = "'.$layer['event_type'].'" AND frm_name = "'.$frm_name.'")'; - } - }else{ - if(!empty($layersString)){ - $layersString .= ' OR '; - } - $layersString .= 'event_type = "'.$layer['event_type'].'"'; - } - - if (isset($layer['event_type']) && isset($eventsKeys[$layer['event_type']])) { - $eventKey = $eventsKeys[ $layer['event_type'] ]; - $dbData[$eventKey] = array(); - $dbVisibleData[$eventKey] = false; - $sources[$eventKey] = array( - 'data' => array(), - 'event_type' => $layer['event_type'], - 'res' => $resolution, - 'showInLegend' => false - ); - } - } - - - switch ($resolution) { - case 'm': - $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': - $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(floor($startTimestamp / 300) * 300); - $endInterval->setTimestamp(floor($endTimestamp / 300) * 300); - - $interval = DateInterval::createFromDateString('5 minutes'); - $period = new DatePeriod($beginInterval, $interval, $endInterval); - $periodSeconds = 300000; - - break; - case '15m': - $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(floor($startTimestamp / 900) * 900); - $endInterval->setTimestamp(floor($endTimestamp / 900) * 900); - - $interval = DateInterval::createFromDateString('15 minutes'); - $period = new DatePeriod($beginInterval, $interval, $endInterval); - $periodSeconds = 900000; - - break; - case '30m': - $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(); - $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': - $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); - $periodSeconds = 3600000; - - 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); - } - - //build 0 data array - if($resolution != 'm'){ - $emptyData = array(); - foreach ( $period as $dt ){ - $emptyData[ ($dt->getTimestamp() * 1000) ] = 0; - } - } - - //Query SQL Data - $result = $this->_dbConnection->query($sql); - $i = 1; - $uniqueIds = array(); - $j = 0; - - while ($row = $result->fetch_array(MYSQLI_ASSOC)) { - //Event Name - $key = $row['event_type']; - - $eventKey = $eventsKeys[$key]; - - //Build data array - if($resolution == 'm'){ - $timeStart = (strtotime($row['event_starttime'])* 1000); - $timeEnd = (strtotime($row['event_endtime'])* 1000); - if(($startTimestamp * 1000) > $timeStart){ - $timeStart = ($beginInterval->getTimestamp() * 1000); - } - if(($endTimestamp * 1000) < $timeEnd){ - $timeEnd = ($endInterval->getTimestamp() * 1000); - } - - $modifier = 0; - if($timeStart == $timeEnd){ - $modifier = round(($endTimestamp - $startTimestamp) / (3*60)) * 100; - $startTimeToDisplay = $timeStart - $modifier; - $timeEndToDisplay = $timeEnd + $modifier; - }else{ - $startTimeToDisplay = $timeStart; - $timeEndToDisplay = $timeEnd; - } - - $sources[$eventKey]['data'][$j] = array( - 'x' => $startTimeToDisplay, - 'x2' => $timeEndToDisplay, - 'y' => $j, - 'kb_archivid' => $row['kb_archivid'], - '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 - ); - - if($timeStart == $timeEnd){ - $sources[$eventKey]['data'][$j]['zeroSeconds'] = true; - } - - 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); - } - - if($visibleEndTimestamp >= strtotime($row['event_starttime']) && $visibleStartTimestamp <= strtotime($row['event_endtime'])){ - $dbVisibleData[$eventKey] = true; - } - - $uniqueIds[$row['frm_specificid']] = $j; - $j++; - }else{ - $timestamp = (strtotime($row['date'])* 1000); - $dbData[$eventKey][$timestamp] = (int)$row['count']; - - if($visibleEndTimestamp >= strtotime($row['date']) && $visibleStartTimestamp <= strtotime($row['date'])){ - $dbVisibleData[$eventKey] = true; - } - } - $i++; - } - - //Fill 0 values rows - if($resolution != 'm'){ - foreach($dbData as $key=>$row){ - foreach($emptyData as $timestamp=>$count){ - if(isset($dbData[$key]) && isset($dbData[$key][ $timestamp ])){ - $count = $dbData[$key][ $timestamp ]; - } - $sources[$key]['data'][] = array($timestamp, (int)$count); - } - } - }else{ - ksort($sources); - $i = 1; - - $levels = array(); - 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 - foreach($levels as $row=>$events){ - //we only need to check the last event if they are already sorted - $last = end($events); - //does the current event start after the end time of the last event in this level - if($event['x'] >= $last['x2']){ - //add to this level and break out of the inner loop - $event['y'] = $row; - $levels[$row][] = $event; - $data[] = $event; - $placed = true; - break; - } - } - //if not placed in another level, add a new level - if(!$placed){ - $levels[$i] = array($event); - $event['y'] = $i; - $data[] = $event; - $i++; - } - } - $sources[$k]['data'] = $data; - } - - } - - //Remove not visible events - foreach($dbVisibleData as $k => $isVisible){ - if($isVisible){ - $sources[$k]['showInLegend'] = true; - } - } - - // Handle non HEK data - 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'] ]; - $sources[$eventKey]['data'] = $data; - $sources[$eventKey]['showInLegend'] = true; - } - } - - 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 the data precomputed statistics for the Image Timeline over the given time range. * @param datestring $start Start time for the range to update @@ -2334,83 +1845,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; - } } ?> diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php new file mode 100644 index 000000000..c3254bfa8 --- /dev/null +++ b/src/Event/Api/EventsApi.php @@ -0,0 +1,235 @@ +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->legacyEvents = $legacyEvents ?? new LegacyEvents(); + + $this->sentry->setContext('EventsApi', [ + 'api_url' => $baseUrl, + 'timeout' => $timeout, + 'connect_timeout' => $connectTimeout, + ]); + } + + /** {@inheritdoc} */ + public function getEventsForSourceLegacy(DateTimeInterface $observationTime, string $source): array + { + $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 (\Throwable $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 (\Throwable $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 (\Throwable $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; + } + } + + /** {@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 (\Throwable $e) { + $this->sentry->setContext('EventsApi', [ + 'error' => $e->getMessage(), + ]); + $exception = new EventsApiException("Failed to fetch batch events: " . $e->getMessage(), 0, $e); + $this->sentry->capture($exception); + throw $exception; + } + }; + + // 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 + return $this->legacyEvents->convertAll($merged); + } + + /** + * 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 Decoded JSON response + * @throws \RuntimeException if JSON decoding fails or response is not an array + */ + 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/Api/EventsApiException.php b/src/Event/Api/EventsApiException.php new file mode 100644 index 000000000..8d9bb47b1 --- /dev/null +++ b/src/Event/Api/EventsApiException.php @@ -0,0 +1,8 @@ +>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; + + /** + * 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/Api/LegacyEvents.php b/src/Event/Api/LegacyEvents.php new file mode 100644 index 000000000..c845060ba --- /dev/null +++ b/src/Event/Api/LegacyEvents.php @@ -0,0 +1,96 @@ + $obs) { + $result[$timestamp] = $this->convert($eventTypes, $events, $obs); + } + + return $result; + } + + /** + * Convert one timestamp's batch data to legacy event_categories format. + * Merges static event data with per-timestamp rotated coordinates, + * shifts footprint polygons by the rotation delta, + * and rebuilds the category/group/data hierarchy. + * + * @param array $eventTypes Category/group structure with event_ids references + * @param array $events Static event data keyed by event ID + * @param array $obs Rotated coordinates keyed by event ID for this timestamp + * @return array Legacy format: [{pin, name, groups: [{name, data: [...]}]}] + */ + public function convert(array $eventTypes, array $events, array $obs): array + { + $categories = []; + + foreach ($eventTypes as $et) { + $groups = []; + + foreach ($et['groups'] as $group) { + $data = []; + + foreach ($group['event_ids'] as $eventId) { + if (!isset($obs[$eventId])) continue; + + $event = $events[$eventId] ?? null; + if (!$event) continue; + + $coords = $obs[$eventId]; + + $legacyEvent = $event; + $legacyEvent['id'] = $eventId; + $legacyEvent['hv_hpc_x'] = $coords['hv_hpc_x']; + $legacyEvent['hv_hpc_y'] = $coords['hv_hpc_y']; + $legacyEvent['hv_hpc_x_final'] = $coords['hv_hpc_x']; + $legacyEvent['hv_hpc_y_final'] = $coords['hv_hpc_y']; + + $dx = $coords['hv_hpc_x'] - $event['hv_hpc_x']; + $dy = $coords['hv_hpc_y'] - $event['hv_hpc_y']; + if (!empty($event['footprint'])) { + $legacyEvent['footprint'] = array_map( + fn($p) => ['x' => $p['x'] + $dx, 'y' => $p['y'] + $dy], + $event['footprint'] + ); + } + + $data[] = $legacyEvent; + } + + if (!empty($data)) { + $groups[] = ['name' => $group['name'], 'data' => $data]; + } + } + + if (!empty($groups)) { + $categories[] = [ + 'pin' => $et['pin'], + 'name' => $et['name'], + 'groups' => $groups + ]; + } + } + + return $categories; + } +} diff --git a/src/Event/Api/LegacyEventsInterface.php b/src/Event/Api/LegacyEventsInterface.php new file mode 100644 index 000000000..7c439f737 --- /dev/null +++ b/src/Event/Api/LegacyEventsInterface.php @@ -0,0 +1,27 @@ + + * @license http://www.mozilla.org/MPL/MPL-1.1.html Mozilla Public License 1.1 + * @link https://github.com/Helioviewer-Project + */ + +namespace Helioviewer\Api\Event; + +use ArrayAccess; +use Countable; +use IteratorAggregate; +use ArrayIterator; +use Traversable; + +class EventSelections implements ArrayAccess, Countable, IteratorAggregate +{ + public static array $event_types_map = [ + 'HEK' => [ + 'AR' => 'Active Region', + 'CE' => 'CME', + 'CH' => 'Coronal Hole', + 'EF' => 'Emerging Flux', + 'FI' => 'Filament', + 'FL' => 'Flare', + 'SG' => 'Sigmoid', + 'CC' => 'Coronal Cavity', + 'CD' => 'Coronal Dimming', + 'CJ' => 'Coronal Jet', + 'CR' => 'Coronal Rain', + 'CW' => 'Coronal Wave', + 'ER' => 'Eruption', + 'FA' => 'Filament Activation', + 'FE' => 'Filament Eruption', + 'LP' => 'Loop', + 'OS' => 'Oscillation', + 'PG' => 'Plage', + 'SP' => 'Spray Surge', + 'SS' => 'Sunspot', + 'OT' => 'Other', + 'NR' => 'Nothing Reported', + 'TO' => 'Topological Object', + 'HY' => 'Hypothesis', + 'BU' => 'UVBurst', + 'EE' => 'Explosive Event', + 'PB' => 'Prominence Bubble', + 'PT' => 'Peacock Tail', + 'EP' => 'SEPs', + 'IC' => 'ICMEs', + 'SR' => 'SIRs', + // 'UNK' => 'Unknown', // Not in events API + ], + 'CCMC' => [ + 'C3' => 'DONKI', + 'FP' => 'Solar Flare Predictions', + ], + 'RHESSI' => [ + 'F2' => 'Solar Flares', + ], + ]; + + 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)) { + $stripped = stripslashes($events_state_string); + // Remove only the outermost [ and ] + if (str_starts_with($stripped, '[') && str_ends_with($stripped, ']')) { + $stripped = substr($stripped, 1, -1); + } + $event_strings = explode("],[", $stripped); + + // Process individual events in string + foreach ($event_strings as $es) { + + $event_pieces = explode(",", $es); + + // there should be 3 elements + if (count($event_pieces) < 3) { + continue; + } + + list($event_type, $combined_frms, $visible) = $event_pieces; + + // Find the source (HEK, CCMC, RHESSI) and label for this event_type + $source = null; + $label = null; + foreach (self::$event_types_map as $src => $types) { + if (array_key_exists($event_type, $types)) { + $source = $src; + $label = $types[$event_type]; + break; + } + } + + // Skip if event_type not found in map + if ($source === null || $label === null) { + continue; + } + + $frms = explode(";", $combined_frms); + + // If 'all' or empty frms, just use SOURCE>>LABEL + if (empty($combined_frms) || $combined_frms === 'all' || in_array('all', $frms)) { + $selections[] = $source . '>>' . $label; + } else { + // For each specific FRM, create SOURCE>>LABEL>>FRM + foreach ($frms as $frm) { + $frm = trim($frm); + if (!empty($frm)) { + $selections[] = $source . '>>' . $label . '>>' . $frm; + } + } + } + } + } + + return new self($selections); + } + + // IteratorAggregate implementation + public function getIterator(): Traversable + { + return new ArrayIterator($this->selections); + } + + // Countable implementation + public function count(): int + { + return count($this->selections); + } + + // ArrayAccess implementation + public function offsetExists($offset): bool + { + return isset($this->selections[$offset]); + } + + public function offsetGet($offset): mixed + { + return $this->selections[$offset] ?? null; + } + + public function offsetSet($offset, $value): void + { + if (is_null($offset)) { + $this->selections[] = $value; + } else { + $this->selections[$offset] = $value; + } + } + + public function offsetUnset($offset): void + { + unset($this->selections[$offset]); + } +} diff --git a/src/Event/EventsStateManager.php b/src/Event/EventsStateManager.php index 15b685365..750ab224a 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; @@ -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/Event/Timeline/AggregatedCoverage.php b/src/Event/Timeline/AggregatedCoverage.php new file mode 100644 index 000000000..50a3d5cd9 --- /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->extendedStartSec(), + $range->extendedEndSec(), + $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..be882cd87 --- /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 using extended range + $response = $eventsApi->getEventsInRange($range->extendedStartSec(), $range->extendedEndSec(), $paths); + $events = $response['events'] ?? []; + + // 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' => false + ]; + } + + // Convert event start/end times to milliseconds + $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 ($range->extendedStart() > $timeStart) { + $timeStart = $range->extendedStart(); + } + if ($range->extendedEnd() < $timeEnd) { + $timeEnd = $range->extendedEnd(); + } + + // 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..092bb4b74 --- /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..ed14f1941 --- /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..0165370cb --- /dev/null +++ b/src/Event/Timeline/TimeRange.php @@ -0,0 +1,62 @@ += end + */ + public function __construct($start, $end, $current) + { + $start = filter_var($start, FILTER_VALIDATE_INT); + $end = filter_var($end, FILTER_VALIDATE_INT); + $current = filter_var($current, FILTER_VALIDATE_INT); + + if ($start === false || $end === false || $current === false) { + throw new InvalidArgumentException('start, end, and current must be integer timestamps in milliseconds'); + } + + if ($start >= $end) { + throw new InvalidArgumentException('start must be less than end'); + } + + $this->start = $start; + $this->end = $end; + $this->current = $current; + + $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 new file mode 100644 index 000000000..aaf213392 --- /dev/null +++ b/src/Event/Timeline/Timeline.php @@ -0,0 +1,86 @@ +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); + + $data = $this->strategy->execute( + $this->eventsApi, + $this->range, + $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/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index 1b5160d8b..2f6379af5 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -19,8 +19,66 @@ 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\Api\EventsApi; +use Helioviewer\Api\Event\Api\EventsApiException; + 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', + ]; + + /** + * 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; @@ -41,7 +99,6 @@ class Image_Composite_HelioviewerCompositeImage { protected $scaleType; protected $scaleX; protected $scaleY; - protected $maxPixelScale; protected $roi; protected $imageScale; protected bool $grayscale; @@ -59,6 +116,8 @@ class Image_Composite_HelioviewerCompositeImage { protected $switchSources; protected $celestialBodiesLabels; protected $celestialBodiesTrajectories; + protected $eventsApi; + protected array $batchEventResponse; /** * Creates a new HelioviewerCompositeImage instance @@ -94,9 +153,11 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi 'switchSources' => false, 'grayscale' => false, 'eclipse' => false, - 'moon' => false + 'moon' => false, + 'eventsApi' => null, + 'batchEventResponse' => [] ); - + $options = array_replace($defaults, $options); $this->width = $roi->getPixelWidth(); @@ -104,6 +165,8 @@ 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->batchEventResponse = $options['batchEventResponse']; $this->layers = $layers; $this->eventsManager = $eventsManager; $this->movieIcons = $movieIcons; @@ -131,8 +194,6 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi $this->celestialBodiesLabels = $celestialBodies['labels']; $this->celestialBodiesTrajectories = $celestialBodies['trajectories']; - - $this->maxPixelScale = 0.60511022; // arcseconds per pixel } /** @@ -583,20 +644,24 @@ 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. - $hek = new Event_HEKAdapter(); - $event_categories = $hek->getNormalizedEvents($this->date, Array()); + // Fetch events via batch (movies have pre-fetched, screenshots fetch for single timestamp) + if (empty($this->batchEventResponse)) { + try { + $this->batchEventResponse = $this->eventsApi->getEventsBatch( + [$this->date], + EventsApi::VALID_SOURCES + ); + } catch (EventsApiException $e) { + // Already captured to Sentry by EventsApi + } catch (\Throwable $e) { + Sentry::capture($e); + } + } - $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)); + $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; $add_label_visibility_and_concept = function($events_data, $event_cat_pin, $event_group_name) use ($events_manager) { @@ -667,62 +732,59 @@ 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 + // 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 ) { - $marker = new IMagick( HV_ROOT_DIR - . '/resources/images/eventMarkers/' - . $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())); + $type = $event['type'] ?? 'UNK'; + $path = self::resolveMarkerPath($markerDir, $type); + if (!isset($markerCache[$path])) { + $markerCache[$path] = new IMagick($path); + } + $marker = clone $markerCache[$path]; - $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; @@ -732,14 +794,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 ) { @@ -778,8 +832,9 @@ private function _addEventLayer($imagickImage) { } } - if ( isset($marker) ) { - $marker->destroy(); + // Cleanup cached marker images + foreach ($markerCache as $m) { + $m->destroy(); } } @@ -1494,50 +1549,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 diff --git a/src/Module/BaseModule.php b/src/Module/BaseModule.php new file mode 100644 index 000000000..ee31f73c5 --- /dev/null +++ b/src/Module/BaseModule.php @@ -0,0 +1,88 @@ +_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 + * + * @param int $code HTTP status code + * @param string $message Status message + * @param mixed $data Response data + * + * @return void + */ + protected 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 + */ + 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..338e9a3d9 100644 --- a/src/Module/JHelioviewer.php +++ b/src/Module/JHelioviewer.php @@ -13,28 +13,14 @@ * @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\BaseModule; +use Helioviewer\Api\Module\ModuleInterface; use Helioviewer\Api\Sentry\Sentry; -class Module_JHelioviewer implements Module { +class Module_JHelioviewer extends BaseModule 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/interface.Module.php b/src/Module/ModuleInterface.php similarity index 91% rename from src/Module/interface.Module.php rename to src/Module/ModuleInterface.php index eb03c3eff..71b9dfc66 100644 --- a/src/Module/interface.Module.php +++ b/src/Module/ModuleInterface.php @@ -11,7 +11,10 @@ * @license http://www.mozilla.org/MPL/MPL-1.1.html Mozilla Public License 1.1 * @link https://github.com/Helioviewer-Project */ -interface Module { + +namespace Helioviewer\Api\Module; + +interface ModuleInterface { /** * Executes the requested action * diff --git a/src/Module/Movies.php b/src/Module/Movies.php index c58d6f913..10555037d 100644 --- a/src/Module/Movies.php +++ b/src/Module/Movies.php @@ -12,27 +12,14 @@ * @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\BaseModule; +use Helioviewer\Api\Module\ModuleInterface; use Helioviewer\Api\Event\EventsStateManager; - use Helioviewer\Api\Sentry\Sentry; -class Module_Movies implements Module { +class Module_Movies extends BaseModule 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 @@ -1462,33 +1449,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 +1591,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..33f9372cf 100644 --- a/src/Module/SolarBodies.php +++ b/src/Module/SolarBodies.php @@ -1,7 +1,7 @@ _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 @@ -413,33 +410,6 @@ private function _nearestTime($requestTime){ * * @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; - } - public function getValidationRules(): array { switch( $this->_params['action'] ) { case 'getSolarBodies': diff --git a/src/Module/SolarEvents.php b/src/Module/SolarEvents.php index f4e96af95..e22dc7ec0 100644 --- a/src/Module/SolarEvents.php +++ b/src/Module/SolarEvents.php @@ -12,27 +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'; -require_once HV_ROOT_DIR . "/../src/Helper/EventInterface.php"; - +use Helioviewer\Api\Module\BaseModule; +use Helioviewer\Api\Module\ModuleInterface; use Helioviewer\Api\Sentry\Sentry; +use Helioviewer\Api\Event\Api\EventsApiException; -class Module_SolarEvents implements Module { - - 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(); - } +class Module_SolarEvents extends BaseModule implements ModuleInterface { /** * execute @@ -203,53 +188,35 @@ 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() { - // 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")); - // 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'); + // All event sources supported by the Events API + $allSources = ['CCMC', 'HEK', 'RHESSI']; - // Check if any specific datasources were requested + // Determine which sources to query 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(); - } + } else { + $sources = $allSources; + } - // Query the rest of the data - $data = Helper_EventInterface::GetEvents($start, $length, $observationTime, $sources); + // Fetch events from each source via EventsApi + $data = []; - // 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); + foreach ($sources as $source) { + try { + $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); + } catch (\Throwable $e) { + Sentry::capture($e); + return $this->_sendResponse(500, 'Internal Server Error', 'Failed to fetch events from ' . $source); + } } header("Content-Type: application/json"); - echo json_encode($data); } diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index 7bc198b23..b9fb11091 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -13,29 +13,17 @@ * @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\BaseModule; +use Helioviewer\Api\Module\ModuleInterface; use Helioviewer\Api\Event\EventsStateManager; +use Helioviewer\Api\Event\Timeline\Timeline as EventTimeline; +use Helioviewer\Api\Event\Api\EventsApiException; use Helioviewer\Api\Sentry\Sentry; -class Module_WebClient implements Module { - - 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(); - } +class Module_WebClient extends BaseModule implements ModuleInterface { /** * execute @@ -542,7 +530,7 @@ public function postScreenshot() $scaleY, $json_params['date'], $roi, - $json_params + array_merge($json_params, ['eventsApi' => $this->eventsApi()]) ); // Display screenshot @@ -639,7 +627,7 @@ public function takeScreenshot() { $scaleY, $this->_params['date'], $roi, - $this->_options + array_merge($this->_options, ['eventsApi' => $this->eventsApi()]) ); // Display screenshot @@ -750,7 +738,7 @@ public function reTakeScreenshot($screenshotId) { $metaData['scaleY'], $metaData['observationDate'], $roi, - $options + array_merge($options, ['eventsApi' => $this->eventsApi()]) ); } @@ -946,128 +934,99 @@ 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'])) { + try { + $timeline = new EventTimeline( + $this->_options['eventLayers'], + $this->_options['startDate'] ?? null, + $this->_options['endDate'] ?? null, + $this->_options['currentDate'] ?? null, + $this->eventsApi() + ); + $this->_printJSON($timeline->execute()); + } catch (InvalidArgumentException $e) { + return $this->_sendResponse(400, 'Invalid time parameters', $e->getMessage()); + } catch (Exception $e) { + // EventsApiException already captured to Sentry by EventsApi + if (!($e instanceof EventsApiException)) { + Sentry::capture($e); + } + return $this->_sendResponse(500, 'Internal server error', $e->getMessage()); + } + } + } + + /** + * Returns data coverage for IMAGE layers. + * Queries the data_coverage_30_min table for image availability data. + */ + public function getDataCoverageForLayers() { include_once HV_ROOT_DIR.'/../src/Helper/HelioviewerLayers.php'; - 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 image layers (e.g., "[SDO,AIA,171,1,100]") + $layers = new Helper_HelioviewerLayers($this->_options['imageLayers']); - // Events Layers - if(!empty($this->_options['eventLayers'])){ - $events = new Helper_HelioviewerEvents($this->_options['eventLayers']); - }else{ - $events = null; - } + // Validate and parse time parameters (inline) + $start = $this->_options['startDate'] ?? null; + $end = $this->_options['endDate'] ?? null; - $start = @$this->_options['startDate']; if ($start && !preg_match('/^[0-9]+$/', $start)) { - die("Invalid start parameter: $start"); + 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)) { - die("Invalid end parameter: $end"); - } - $current = @$this->_options['currentDate']; - if ($current && !preg_match('/^[0-9]+$/', $current)) { - die("Invalid end parameter: $current"); + return $this->_sendResponse(400, 'Invalid time parameters', 'endDate must be numeric timestamp in milliseconds'); } + if (!$start) $start = 0; if (!$end) $end = time() * 1000; - if (!$current) $current = 0; - // set some utility variables $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'; - $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'; - } + $dateEnd->setTimestamp(intval($end / 1000)); - 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', ''); - } + $dateStart = new DateTime(); + $dateStart->setTimestamp(intval($start / 1000)); + // 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 */ @@ -1392,7 +1351,8 @@ public function getEclipseImage() { [ 'grayscale' => true, 'eclipse' => true, - 'moon' => $this->_options['moon'] + 'moon' => $this->_options['moon'], + 'eventsApi' => $this->eventsApi() ] ); $screenshot->display(); @@ -1555,64 +1515,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 @@ -1651,6 +1553,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) { 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); 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]); + } +} 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()); + } } 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/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/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']); + } +} 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']); + } +} 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); + } +} 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..648625871 --- /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 testItShouldCalculateExtendedRange(): void + { + // Range: 1000 to 3000, distance = 2000 + // Extended: 1000-2000 = -1000, 3000+2000 = 5000 + $range = new TimeRange(1000, 3000, 2000); + $this->assertEquals(-1000, $range->extendedStart()); + $this->assertEquals(5000, $range->extendedEnd()); + } + + public function testItShouldConvertExtendedToSeconds(): void + { + $range = new TimeRange(10000, 20000, 15000); + // distance = 10000, extended: 0 to 30000 + $this->assertEquals(0, $range->extendedStartSec()); + $this->assertEquals(30, $range->extendedEndSec()); + } + + public function testExtendedRangeShouldBeTripleWidth(): void + { + $range = new TimeRange(1000, 5000, 3000); + $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 new file mode 100644 index 000000000..0d323d273 --- /dev/null +++ b/tests/unit_tests/events/timeline/TimelineTest.php @@ -0,0 +1,70 @@ + + */ + +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), + // Visible: 1000 to 3000, Extended: -1000 to 5000 + $this->callback(function (TimeRange $range) { + 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']), + // 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()); + } +} 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/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); + } + } +} 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