From 7fc2b688bb45e4e2bd5479163167ff71a8c16878 Mon Sep 17 00:00:00 2001 From: bclayton-usgs Date: Thu, 12 Sep 2019 13:04:00 -0600 Subject: [PATCH 1/8] move aws package over --- .../nshmp/aws/HazardResultSliceLambda.java | 311 +++++++++++++++++ .../aws/HazardResultsMetadataLambda.java | 314 ++++++++++++++++++ .../nshmp/aws/HazardResultsSlicerLambda.java | 257 ++++++++++++++ src/gov/usgs/earthquake/nshmp/aws/Util.java | 41 +++ 4 files changed, 923 insertions(+) create mode 100644 src/gov/usgs/earthquake/nshmp/aws/HazardResultSliceLambda.java create mode 100644 src/gov/usgs/earthquake/nshmp/aws/HazardResultsMetadataLambda.java create mode 100644 src/gov/usgs/earthquake/nshmp/aws/HazardResultsSlicerLambda.java create mode 100644 src/gov/usgs/earthquake/nshmp/aws/Util.java diff --git a/src/gov/usgs/earthquake/nshmp/aws/HazardResultSliceLambda.java b/src/gov/usgs/earthquake/nshmp/aws/HazardResultSliceLambda.java new file mode 100644 index 000000000..8609fe782 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/aws/HazardResultSliceLambda.java @@ -0,0 +1,311 @@ +package gov.usgs.earthquake.nshmp.aws; + +import static com.google.common.base.Preconditions.checkState; +import static gov.usgs.earthquake.nshmp.aws.Util.CURVES_FILE; +import static gov.usgs.earthquake.nshmp.aws.Util.MAP_FILE; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.GSON; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.amazonaws.services.s3.model.S3Object; +import com.amazonaws.services.s3.model.S3ObjectInputStream; +import com.google.common.base.Charsets; +import com.google.common.base.Throwables; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; + +import gov.usgs.earthquake.nshmp.aws.Util.LambdaHelper; +import gov.usgs.earthquake.nshmp.calc.Site; +import gov.usgs.earthquake.nshmp.data.Interpolator; +import gov.usgs.earthquake.nshmp.internal.Parsing; +import gov.usgs.earthquake.nshmp.internal.Parsing.Delimiter; +import gov.usgs.earthquake.nshmp.www.ServletUtil; +import gov.usgs.earthquake.nshmp.www.meta.Metadata; +import gov.usgs.earthquake.nshmp.www.meta.Status; + +/** + * AWS Lambda function to read in a curves file from AWS S3 and create slices at + * return periods interest.
+ * + * The results are written to S3 as map.csv bucket. + */ +@SuppressWarnings("unused") +public class HazardResultSliceLambda implements RequestStreamHandler { + + private static final AmazonS3 S3 = AmazonS3ClientBuilder.defaultClient(); + + private static final String RATE_FMT = "%.8e"; + private static final Function FORMATTER = Parsing.formatDoubleFunction(RATE_FMT); + + private static final int NUMBER_OF_HEADERS = 3; + private static final String CONTENT_TYPE = "text/csv"; + + private static final Interpolator INTERPOLATOR = Interpolator.builder() + .logx() + .logy() + .decreasingX() + .build(); + + @Override + public void handleRequest( + InputStream input, + OutputStream output, + Context context) throws IOException { + LambdaHelper lambdaHelper = new LambdaHelper(input, output, context); + String requestBucket = ""; + + try { + RequestData request = GSON.fromJson(lambdaHelper.requestJson, RequestData.class); + lambdaHelper.logger.log("Request Data: " + GSON.toJson(request) + "\n"); + requestBucket = request.bucket + "/" + request.key; + checkRequest(request); + Response response = processRequest(request); + String json = GSON.toJson(response, Response.class); + lambdaHelper.logger.log("Result: " + json + "\n"); + output.write(json.getBytes()); + output.close(); + } catch (Exception e) { + lambdaHelper.logger.log("\nError: " + Throwables.getStackTraceAsString(e) + "\n\n"); + String message = Metadata.errorMessage(requestBucket, e, false); + output.write(message.getBytes()); + } + } + + private static Response processRequest(RequestData request) throws IOException { + List data = readCurveFile(request); + String outputBucket = request.bucket + "/" + request.key; + StringBuilder csv = new StringBuilder(); + createHeaderString(csv, request); + createDataString(csv, data); + writeResults(request, outputBucket, csv.toString().getBytes(Charsets.UTF_8)); + return new Response(request, outputBucket); + } + + private static List readCurveFile(RequestData request) throws IOException { + S3Object object = S3.getObject(request.bucket, request.key + "/" + CURVES_FILE); + S3ObjectInputStream input = object.getObjectContent(); + BufferedReader reader = new BufferedReader(new InputStreamReader(input)); + List lines = reader.lines().collect(Collectors.toList()); + reader.close(); + + Optional> header = lines.stream() + .filter(line -> !line.startsWith("#")) + .findFirst() + .map(line -> Parsing.splitToList(line, Delimiter.COMMA)); + + checkState(header.isPresent(), "Curve file is empty"); + + List keys = header.get().subList(0, NUMBER_OF_HEADERS); + List imls = header.get().subList(NUMBER_OF_HEADERS, header.get().size()) + .stream() + .map(iml -> Double.parseDouble(iml)) + .collect(Collectors.toList()); + + List data = new ArrayList<>(); + lines.stream() + .filter(line -> !line.startsWith("#")) + .skip(1) + .forEach(line -> { + data.add(curveToInterpolatedData(request, line, keys, imls)); + }); + + return data; + } + + private static InterpolatedData curveToInterpolatedData( + RequestData request, + String line, + List keys, + List imls) { + List values = Parsing.splitToList(line, Delimiter.COMMA); + List gms = values.subList(NUMBER_OF_HEADERS, values.size()) + .stream() + .map(gm -> Double.parseDouble(gm)) + .collect(Collectors.toList()); + values = values.subList(0, NUMBER_OF_HEADERS); + + Site site = buildSite(keys, values); + List interpolatedValues = request.slices.stream() + .map(returnPeriod -> INTERPOLATOR.findX(imls, gms, returnPeriod)) + .collect(Collectors.toList()); + + return new InterpolatedData(site, interpolatedValues); + } + + private static Site buildSite(List keys, List values) { + Double lat = null; + Double lon = null; + String name = null; + + for (int index = 0; index < keys.size(); index++) { + String key = keys.get(index); + String value = values.get(index); + + switch (key) { + case Keys.LAT: + lat = Double.parseDouble(value); + break; + case Keys.LON: + lon = Double.parseDouble(value); + break; + case Keys.NAME: + name = value; + break; + default: + throw new IllegalStateException("Unsupported site key: " + key); + } + } + + return Site.builder() + .location(lat, lon) + .name(name) + .build(); + } + + private static void checkRequest(RequestData request) { + if (request.bucket == null) { + throw new RuntimeException("Request does not contain a S3 bucket"); + } + + if (request.key == null) { + throw new RuntimeException("Request does not contain a S3 key"); + } + + if (request.slices == null) { + throw new RuntimeException("Request does not contain returnPeriods"); + } + } + + private static void createDataString(StringBuilder builder, List data) { + data.forEach(datum -> { + List locData = Lists.newArrayList( + datum.site.name, + String.format("%.5f", datum.site.location.lon()), + String.format("%.5f", datum.site.location.lat())); + builder.append(toLine(locData, datum.values) + "\n"); + }); + } + + private static String toLine( + Iterable strings, + Iterable values) { + return Parsing.join( + Iterables.concat(strings, Iterables.transform(values, FORMATTER::apply)), + Delimiter.COMMA); + } + + private static void createHeaderString(StringBuilder builder, RequestData request) { + List header = Lists.newArrayList(Keys.NAME, Keys.LON, Keys.LAT); + builder.append(toLine(header, request.slices) + "\n"); + } + + private static void writeResults( + RequestData request, + String outputBucket, + byte[] result) throws IOException { + ObjectMetadata metadata = new ObjectMetadata(); + + InputStream input = new ByteArrayInputStream(result); + metadata.setContentType(CONTENT_TYPE); + metadata.setContentLength(result.length); + PutObjectRequest putRequest = new PutObjectRequest( + request.bucket, + request.key + "/" + MAP_FILE, + input, + metadata); + S3.putObject(putRequest); + input.close(); + } + + static class RequestData { + String bucket; + String key; + List slices; + + private RequestData(Builder builder) { + bucket = builder.bucket; + key = builder.key; + slices = builder.slices; + } + + static Builder builder() { + return new Builder(); + } + + static class Builder { + private String bucket; + private String key; + private List slices; + + Builder bucket(String bucket) { + this.bucket = bucket; + return this; + } + + Builder key(String key) { + this.key = key; + return this; + } + + Builder slices(List slices) { + this.slices = slices; + return this; + } + + RequestData build() { + return new RequestData(this); + } + + } + + } + + private static class Response { + final String status; + final String date; + final RequestData request; + final String csv; + + Response(RequestData request, String outputBucket) { + status = Status.SUCCESS.toString(); + date = ZonedDateTime.now().format(ServletUtil.DATE_FMT); + this.request = request; + this.csv = outputBucket + "/" + MAP_FILE; + } + + } + + private static class InterpolatedData { + Site site; + List values; + + InterpolatedData(Site site, List values) { + this.site = site; + this.values = values; + } + } + + private static class Keys { + static final String LAT = "lat"; + static final String LON = "lon"; + static final String NAME = "name"; + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/aws/HazardResultsMetadataLambda.java b/src/gov/usgs/earthquake/nshmp/aws/HazardResultsMetadataLambda.java new file mode 100644 index 000000000..1d1183e4c --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/aws/HazardResultsMetadataLambda.java @@ -0,0 +1,314 @@ +package gov.usgs.earthquake.nshmp.aws; + +import static gov.usgs.earthquake.nshmp.aws.Util.CURVES_FILE; +import static gov.usgs.earthquake.nshmp.aws.Util.MAP_FILE; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.GSON; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.ListObjectsV2Request; +import com.amazonaws.services.s3.model.ListObjectsV2Result; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.google.common.base.Enums; +import com.google.common.base.Throwables; + +import gov.usgs.earthquake.nshmp.aws.Util.LambdaHelper; +import gov.usgs.earthquake.nshmp.calc.DataType; +import gov.usgs.earthquake.nshmp.eq.model.SourceType; +import gov.usgs.earthquake.nshmp.gmm.Gmm; +import gov.usgs.earthquake.nshmp.gmm.Imt; +import gov.usgs.earthquake.nshmp.internal.Parsing; +import gov.usgs.earthquake.nshmp.internal.Parsing.Delimiter; +import gov.usgs.earthquake.nshmp.www.ServletUtil; +import gov.usgs.earthquake.nshmp.www.meta.Metadata; +import gov.usgs.earthquake.nshmp.www.meta.Status; + +/** + * AWS Lambda function to list all hazard results in the nshmp-hazout S3 bucket + * that contain a map.csv file. + */ +@SuppressWarnings("unused") +public class HazardResultsMetadataLambda implements RequestStreamHandler { + + private static final AmazonS3 S3 = AmazonS3ClientBuilder.defaultClient(); + + private static final int IMT_DIR_BACK_FROM_TOTAL = 2; + private static final int IMT_DIR_BACK_FROM_SOURCE = 4; + private static final String S3_BUCKET = "nshmp-hazout"; + private static final String RESULT_BUCKET = "nshmp-haz-lambda"; + private static final String RESULT_KEY = "nshmp-haz-aws-results-metadata.json"; + + @Override + public void handleRequest( + InputStream input, + OutputStream output, + Context context) throws IOException { + LambdaHelper lambdaHelper = new LambdaHelper(input, output, context); + + try { + Response response = processRequest(); + String json = GSON.toJson(response, Response.class); + uploadResults(json); + output.write(json.getBytes()); + output.close(); + } catch (Exception e) { + lambdaHelper.logger.log("\nError: " + Throwables.getStackTraceAsString(e) + "\n\n"); + String message = Metadata.errorMessage("", e, false); + output.write(message.getBytes()); + } + } + + private static Response processRequest() { + Map curvesMapResults = new HashMap<>(); + Set users = getUsers(); + + for (String file : new String[] { CURVES_FILE, MAP_FILE }) { + List hazardResults = listObjects(users, file); + CurvesMapResult result = new CurvesMapResult(users, hazardResults); + curvesMapResults.put(file, result); + } + + Result result = new Result(curvesMapResults.get(CURVES_FILE), curvesMapResults.get(MAP_FILE)); + return new Response(result); + } + + private static List listObjects(Set users, String file) { + ListObjectsV2Request request = new ListObjectsV2Request() + .withBucketName(S3_BUCKET) + .withDelimiter(file); + ListObjectsV2Result s3Result; + List s3Listings = new ArrayList<>(); + + do { + s3Result = S3.listObjectsV2(request); + s3Result.getCommonPrefixes() + .stream() + .map(key -> keyToHazardListing(key)) + .forEach(listing -> s3Listings.add(listing)); + + request.setContinuationToken(s3Result.getNextContinuationToken()); + } while (s3Result.isTruncated()); + + return transformS3Listing(users, s3Listings); + } + + private static List transformS3Listing(Set users, List s3Listings) { + List hazardResults = new ArrayList<>(); + + users.forEach(user -> { + TreeSet resultDirectories = s3Listings.stream() + .filter(listing -> listing.user.equals(user)) + .map(listing -> listing.resultPrefix) + .collect(Collectors.toCollection(TreeSet::new)); + + resultDirectories.forEach(resultPrefix -> { + List s3Filteredlistings = s3Listings.parallelStream() + .filter(listing -> listing.user.equals(user)) + .filter(listing -> listing.resultPrefix.equals(resultPrefix)) + .collect(Collectors.toList()); + + List listings = s3Filteredlistings.parallelStream() + .map(listing -> s3ListingToHazardListing(listing)) + .collect(Collectors.toList()); + + S3Listing s3Listing = s3Filteredlistings.get(0); + String path = s3Listing.path.split(resultPrefix)[0]; + String s3Path = s3Listing.user + "/" + path + resultPrefix; + + hazardResults.add(new HazardResults( + user, + s3Listing.bucket, + resultPrefix, + s3Path, + listings)); + }); + }); + + return hazardResults; + } + + private static HazardListing s3ListingToHazardListing(S3Listing s3Listing) { + return new HazardListing(s3Listing.dataType, s3Listing.path, s3Listing.file); + } + + private static S3Listing keyToHazardListing(String key) { + List keys = Parsing.splitToList(key, Delimiter.SLASH); + HazardDataType dataType = getDataType(keys); + String user = keys.get(0); + String file = keys.get(keys.size() - 1); + String path = keys.subList(1, keys.size() - 1) + .stream() + .collect(Collectors.joining("/")); + + return new S3Listing(user, S3_BUCKET, path, file, dataType); + } + + private static Set getUsers() { + ListObjectsV2Request request = new ListObjectsV2Request() + .withBucketName(S3_BUCKET) + .withDelimiter("/"); + + ListObjectsV2Result listing = S3.listObjectsV2(request); + + return listing.getCommonPrefixes().stream() + .map(prefix -> prefix.replace("/", "")) + .collect(Collectors.toCollection(TreeSet::new)); + } + + private static HazardDataType getDataType(List keys) { + String sourceType = keys.get(keys.size() - IMT_DIR_BACK_FROM_TOTAL); + HazardDataType dataType = null; + String resultDirectory = null; + Imt imt = null; + + if (Enums.getIfPresent(SourceType.class, sourceType).isPresent()) { + imt = Imt.valueOf(keys.get(keys.size() - IMT_DIR_BACK_FROM_SOURCE)); + resultDirectory = keys.get(keys.size() - IMT_DIR_BACK_FROM_SOURCE - 1); + SourceType type = SourceType.valueOf(sourceType); + dataType = new HazardDataType(imt, DataType.SOURCE, type, resultDirectory); + } else if (Enums.getIfPresent(Gmm.class, sourceType).isPresent()) { + imt = Imt.valueOf(keys.get(keys.size() - IMT_DIR_BACK_FROM_SOURCE)); + resultDirectory = keys.get(keys.size() - IMT_DIR_BACK_FROM_SOURCE - 1); + Gmm type = Gmm.valueOf(sourceType); + dataType = new HazardDataType(imt, DataType.GMM, type, resultDirectory); + } else if (Enums.getIfPresent(Imt.class, sourceType).isPresent()) { + Imt type = Imt.valueOf(sourceType); + resultDirectory = keys.get(keys.size() - IMT_DIR_BACK_FROM_TOTAL - 1); + imt = type; + dataType = new HazardDataType(imt, DataType.TOTAL, type, resultDirectory); + } else { + throw new RuntimeException("Source type [" + sourceType + "] not supported"); + } + + return dataType; + } + + private static void uploadResults(String results) { + byte[] bytes = results.getBytes(); + ByteArrayInputStream input = new ByteArrayInputStream(bytes); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(bytes.length); + metadata.setContentType("application/json"); + + PutObjectRequest request = new PutObjectRequest( + RESULT_BUCKET, + RESULT_KEY, + input, + metadata); + + S3.putObject(request); + } + + static class HazardDataType> { + final Imt imt; + final DataType type; + final transient String resultPrefix; + final E sourceType; + + HazardDataType(Imt imt, DataType type, E sourceType, String resultPrefix) { + this.imt = imt; + this.type = type; + this.resultPrefix = resultPrefix; + this.sourceType = sourceType; + } + } + + private static class HazardResults { + final String user; + final String bucket; + final String resultPrefix; + final String path; + final List listings; + + HazardResults( + String user, + String bucket, + String resultPrefix, + String path, + List listings) { + this.user = user; + this.bucket = bucket; + this.resultPrefix = resultPrefix; + this.path = path; + this.listings = listings; + } + } + + private static class HazardListing { + final HazardDataType dataType; + final String file; + final String path; + + HazardListing(HazardDataType dataType, String path, String file) { + this.dataType = dataType; + this.file = file; + this.path = path; + } + } + + private static class S3Listing { + final String user; + final String bucket; + final String path; + final String file; + final String resultPrefix; + final HazardDataType dataType; + + S3Listing(String user, String bucket, String path, String file, HazardDataType dataType) { + this.user = user; + this.bucket = bucket; + this.path = path; + this.file = file; + this.resultPrefix = dataType.resultPrefix; + this.dataType = dataType; + } + } + + private static class CurvesMapResult { + final Set users; + final List hazardResults; + + CurvesMapResult(Set users, List hazardResults) { + this.users = users; + this.hazardResults = hazardResults; + } + } + + private static class Result { + final CurvesMapResult curves; + final CurvesMapResult map; + + Result(CurvesMapResult curves, CurvesMapResult map) { + this.curves = curves; + this.map = map; + } + } + + private static class Response { + final String status; + final String date; + final Result result; + + Response(Result result) { + status = Status.SUCCESS.toString(); + date = ZonedDateTime.now().format(ServletUtil.DATE_FMT); + this.result = result; + } + } +} diff --git a/src/gov/usgs/earthquake/nshmp/aws/HazardResultsSlicerLambda.java b/src/gov/usgs/earthquake/nshmp/aws/HazardResultsSlicerLambda.java new file mode 100644 index 000000000..340dc58ea --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/aws/HazardResultsSlicerLambda.java @@ -0,0 +1,257 @@ +package gov.usgs.earthquake.nshmp.aws; + +import static gov.usgs.earthquake.nshmp.aws.Util.CURVES_FILE; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.GSON; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +import com.amazonaws.services.ec2.AmazonEC2; +import com.amazonaws.services.ec2.AmazonEC2ClientBuilder; +import com.amazonaws.services.ec2.model.DescribeInstancesRequest; +import com.amazonaws.services.ec2.model.DescribeInstancesResult; +import com.amazonaws.services.ec2.model.Instance; +import com.amazonaws.services.ec2.model.Reservation; +import com.amazonaws.services.lambda.AWSLambda; +import com.amazonaws.services.lambda.AWSLambdaClientBuilder; +import com.amazonaws.services.lambda.model.InvokeRequest; +import com.amazonaws.services.lambda.model.InvokeResult; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.ObjectListing; +import com.google.common.base.Throwables; + +import gov.usgs.earthquake.nshmp.aws.Util.LambdaHelper; +import gov.usgs.earthquake.nshmp.internal.Parsing; +import gov.usgs.earthquake.nshmp.internal.Parsing.Delimiter; +import gov.usgs.earthquake.nshmp.www.ServletUtil; +import gov.usgs.earthquake.nshmp.www.meta.Metadata; +import gov.usgs.earthquake.nshmp.www.meta.Status; + +/** + * AWS Lambda function to read in hazard results from S3 and to create slices of + * return periods of interest. + * + * @see HazardResultSliceLambda + */ +@SuppressWarnings("unused") +public class HazardResultsSlicerLambda implements RequestStreamHandler { + + private static final AmazonS3 S3 = AmazonS3ClientBuilder.defaultClient(); + private static final AmazonEC2 EC2 = AmazonEC2ClientBuilder.defaultClient(); + private static final AWSLambda LAMBDA_CLIENT = AWSLambdaClientBuilder.defaultClient(); + + private static final String LAMBDA_CALL = "nshmp-haz-result-slice"; + private static final String ZIP_LAMBDA_CALL = "nshmp-haz-zip-results"; + private static final String INSTANCE_STATUS = "terminated"; + + private static final int MAX_INSTANCE_CHECK = 100; + private static final int INSTANCE_CHECK_TIMEOUT = 10 * 1000; + + @Override + public void handleRequest( + InputStream input, + OutputStream output, + Context context) throws IOException { + LambdaHelper lambdaHelper = new LambdaHelper(input, output, context); + String requestBucket = ""; + + try { + RequestData request = GSON.fromJson(lambdaHelper.requestJson, RequestData.class); + requestBucket = String.format("%s/%s", request.bucket, request.key); + lambdaHelper.logger.log("Request Data: " + GSON.toJson(request) + "\n\n"); + checkRequest(request); + checkBucket(request); + Response response = processRequest(lambdaHelper, request); + output.write(GSON.toJson(response, Response.class).getBytes()); + } catch (Exception e) { + lambdaHelper.logger.log("\nError: " + Throwables.getStackTraceAsString(e) + "\n\n"); + String message = Metadata.errorMessage(requestBucket, e, false); + output.write(message.getBytes()); + } + } + + private static Response processRequest( + LambdaHelper lambdaHelper, + RequestData request) throws IOException, InterruptedException { + ObjectListing objectListing = S3.listObjects(request.bucket, request.key); + List> futures = new ArrayList<>(); + + objectListing.getObjectSummaries() + .parallelStream() + .filter(summary -> summary.getKey().endsWith(CURVES_FILE)) + .forEach(summary -> { + String name = summary.getKey(); + lambdaHelper.logger.log("Reading: " + name + "\n"); + try { + futures.add(processCurveFile(request, lambdaHelper, name)); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + + futures.forEach(CompletableFuture::join); + lambdaHelper.logger.log("Zipping results"); + zipResults(request); + return new Response(request); + } + + private static CompletableFuture processCurveFile( + RequestData request, + LambdaHelper lambdaHelper, + String curvesPath) throws IOException { + return readCurveFile(request, curvesPath) + .thenAcceptAsync(result -> { + checkLambdaResponse(result); + }); + } + + private static CompletableFuture readCurveFile( + RequestData request, + String curvesPath) throws IOException { + List names = Arrays.stream(curvesPath.split("/")) + .collect(Collectors.toList()); + names.remove(names.size() - 1); + String key = Parsing.join(names, Delimiter.SLASH); + + HazardResultSliceLambda.RequestData lambdaRequest = HazardResultSliceLambda.RequestData + .builder() + .bucket(request.bucket) + .key(key) + .slices(request.slices) + .build(); + + InvokeRequest invokeRequest = new InvokeRequest() + .withFunctionName(LAMBDA_CALL) + .withPayload(GSON.toJson(lambdaRequest)); + + return CompletableFuture.supplyAsync(() -> { + return LAMBDA_CLIENT.invoke(invokeRequest); + }); + } + + private static void checkRequest(RequestData request) { + if (request.bucket == null) { + throw new RuntimeException("Request does not contain a S3 bucket"); + } + + if (request.key == null) { + throw new RuntimeException("Request does not contain a S3 key"); + } + + if (request.slices == null) { + throw new RuntimeException("Request does not contain slices"); + } + } + + private static void checkBucket(RequestData request) { + if (!S3.doesBucketExistV2(request.bucket)) { + throw new RuntimeException(String.format("S3 bucket [%s] does not exist", request.bucket)); + } + } + + private static void zipResults(RequestData request) throws InterruptedException { + InvokeRequest invokeRequest = new InvokeRequest() + .withFunctionName(ZIP_LAMBDA_CALL) + .withPayload(GSON.toJson(request)); + + InvokeResult result = LAMBDA_CLIENT.invoke(invokeRequest); + checkLambdaResponse(result); + + ZipResultsResponse response = GSON.fromJson( + new String(result.getPayload().array()), + ZipResultsResponse.class); + + waitForInstance(response); + } + + private static void waitForInstance(ZipResultsResponse response) throws InterruptedException { + for (int ii = 0; ii < MAX_INSTANCE_CHECK; ii++) { + DescribeInstancesRequest request = new DescribeInstancesRequest() + .withInstanceIds(response.result.instanceId); + + DescribeInstancesResult instances = EC2.describeInstances(request); + if (isTerminated(instances)) { + return; + } + + Thread.sleep(INSTANCE_CHECK_TIMEOUT); + } + } + + private static boolean isTerminated(DescribeInstancesResult instances) { + for (Reservation reservation : instances.getReservations()) { + for (Instance instance : reservation.getInstances()) { + if (INSTANCE_STATUS.equals(instance.getState().getName())) { + return true; + } + } + } + + return false; + } + + private static void checkLambdaResponse(InvokeResult result) { + try { + LambdaResponse response = GSON.fromJson( + new String(result.getPayload().array()), + LambdaResponse.class); + + if (Status.ERROR.toString().equals(response.status)) { + throw new RuntimeException(response.message); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static class LambdaResponse { + String status; + String message; + } + + private static class ZipResultsResponse extends LambdaResponse { + ZipResult result; + ZipRequest request; + + private static class ZipRequest { + String bucket; + String key; + } + + private static class ZipResult { + String path; + String instanceId; + } + } + + private static class RequestData { + String bucket; + String key; + List slices; + } + + private static class Response { + final String status; + final String date; + final RequestData request; + final String outputBucket; + + Response(RequestData request) { + status = Status.SUCCESS.toString(); + date = ZonedDateTime.now().format(ServletUtil.DATE_FMT); + this.request = request; + this.outputBucket = String.format("%s/%s", request.bucket, request.key); + } + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/aws/Util.java b/src/gov/usgs/earthquake/nshmp/aws/Util.java new file mode 100644 index 000000000..07a2b6d78 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/aws/Util.java @@ -0,0 +1,41 @@ +package gov.usgs.earthquake.nshmp.aws; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.LambdaLogger; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +public class Util { + + static final String CURVES_FILE = "curves.csv"; + static final String MAP_FILE = "map.csv"; + + /** + * Parse the Lambda function {@code InputStream} into an {@code JsonObject}. + */ + static class LambdaHelper { + JsonObject requestJson; + Context context; + LambdaLogger logger; + OutputStream output; + + LambdaHelper(InputStream input, OutputStream output, Context context) + throws UnsupportedEncodingException { + logger = context.getLogger(); + this.context = context; + this.output = output; + + BufferedReader reader = new BufferedReader(new InputStreamReader(input)); + JsonParser parser = new JsonParser(); + + requestJson = parser.parse(reader).getAsJsonObject(); + } + } + +} From 11a74a59e4486a26f0db446b5cf17faf62d64ecc Mon Sep 17 00:00:00 2001 From: bclayton-usgs Date: Thu, 12 Sep 2019 13:04:11 -0600 Subject: [PATCH 2/8] move over www package --- .../nshmp/www/DeaggEpsilonService.java | 356 +++++++++ .../earthquake/nshmp/www/DeaggService.java | 235 ++++++ .../earthquake/nshmp/www/DeaggService2.java | 384 ++++++++++ .../earthquake/nshmp/www/GmmServices.java | 701 ++++++++++++++++++ .../earthquake/nshmp/www/HazardService.java | 526 +++++++++++++ .../earthquake/nshmp/www/HazardService2.java | 383 ++++++++++ src/gov/usgs/earthquake/nshmp/www/Model.java | 81 ++ .../earthquake/nshmp/www/NshmpServlet.java | 106 +++ .../earthquake/nshmp/www/RateService.java | 445 +++++++++++ .../earthquake/nshmp/www/ServletUtil.java | 246 ++++++ .../earthquake/nshmp/www/SourceServices.java | 256 +++++++ src/gov/usgs/earthquake/nshmp/www/Util.java | 150 ++++ .../nshmp/www/UtilitiesService.java | 131 ++++ .../earthquake/nshmp/www/XY_DataGroup.java | 52 ++ .../nshmp/www/meta/Constrained.java | 10 + .../nshmp/www/meta/Constraints.java | 7 + .../nshmp/www/meta/DoubleParameter.java | 27 + .../earthquake/nshmp/www/meta/Edition.java | 90 +++ .../nshmp/www/meta/EditionConstraints.java | 28 + .../nshmp/www/meta/EnumParameter.java | 18 + .../earthquake/nshmp/www/meta/Metadata.java | 299 ++++++++ .../earthquake/nshmp/www/meta/ParamType.java | 13 + .../earthquake/nshmp/www/meta/Region.java | 121 +++ .../nshmp/www/meta/RegionConstraints.java | 21 + .../earthquake/nshmp/www/meta/Status.java | 20 + .../usgs/earthquake/nshmp/www/meta/Util.java | 159 ++++ .../earthquake/nshmp/www/meta/Versions.java | 55 ++ .../nshmp/www/meta/package-info.java | 9 + 28 files changed, 4929 insertions(+) create mode 100644 src/gov/usgs/earthquake/nshmp/www/DeaggEpsilonService.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/DeaggService.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/DeaggService2.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/GmmServices.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/HazardService.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/HazardService2.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/Model.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/NshmpServlet.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/RateService.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/ServletUtil.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/SourceServices.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/Util.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/UtilitiesService.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/XY_DataGroup.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/Constrained.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/Constraints.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/DoubleParameter.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/Edition.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/EditionConstraints.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/EnumParameter.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/Metadata.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/ParamType.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/Region.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/RegionConstraints.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/Status.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/Util.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/Versions.java create mode 100644 src/gov/usgs/earthquake/nshmp/www/meta/package-info.java diff --git a/src/gov/usgs/earthquake/nshmp/www/DeaggEpsilonService.java b/src/gov/usgs/earthquake/nshmp/www/DeaggEpsilonService.java new file mode 100644 index 000000000..0d1096041 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/DeaggEpsilonService.java @@ -0,0 +1,356 @@ +package gov.usgs.earthquake.nshmp.www; + +import static com.google.common.base.Preconditions.checkNotNull; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.GSON; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.MODEL_CACHE_CONTEXT_ID; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.emptyRequest; +import static gov.usgs.earthquake.nshmp.www.Util.readBoolean; +import static gov.usgs.earthquake.nshmp.www.Util.readDouble; +import static gov.usgs.earthquake.nshmp.www.Util.Key.BASIN; +import static gov.usgs.earthquake.nshmp.www.Util.Key.LATITUDE; +import static gov.usgs.earthquake.nshmp.www.Util.Key.LONGITUDE; +import static gov.usgs.earthquake.nshmp.www.Util.Key.MODEL; +import static gov.usgs.earthquake.nshmp.www.Util.Key.VS30; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Properties; +import java.util.Set; + +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableList; + +import gov.usgs.earthquake.nshmp.calc.CalcConfig; +import gov.usgs.earthquake.nshmp.calc.Deaggregation; +import gov.usgs.earthquake.nshmp.calc.Hazard; +import gov.usgs.earthquake.nshmp.calc.HazardCalcs; +import gov.usgs.earthquake.nshmp.calc.Site; +import gov.usgs.earthquake.nshmp.eq.model.HazardModel; +import gov.usgs.earthquake.nshmp.geo.Location; +import gov.usgs.earthquake.nshmp.gmm.Imt; +import gov.usgs.earthquake.nshmp.www.ServletUtil.TimedTask; +import gov.usgs.earthquake.nshmp.www.ServletUtil.Timer; +import gov.usgs.earthquake.nshmp.www.meta.Metadata; +import gov.usgs.earthquake.nshmp.www.meta.Status; + +/** + * Hazard deaggregation service. + * + * @author Peter Powers + */ +@SuppressWarnings("unused") +@WebServlet( + name = "Epsilon Deaggregation Service (experimental)", + description = "USGS NSHMP Hazard Deaggregator", + urlPatterns = { "/deagg-epsilon" }) +public final class DeaggEpsilonService extends NshmpServlet { + + /* Developer notes: See HazardService. */ + + private LoadingCache modelCache; + private URL basinUrl; + + private static final String USAGE = SourceServices.GSON.toJson( + new SourceServices.ResponseData()); + + @Override + @SuppressWarnings("unchecked") + public void init() throws ServletException { + + ServletContext context = getServletConfig().getServletContext(); + Object modelCache = context.getAttribute(MODEL_CACHE_CONTEXT_ID); + this.modelCache = (LoadingCache) modelCache; + + try (InputStream config = + DeaggService2.class.getResourceAsStream("/config.properties")) { + + checkNotNull(config, "Missing config.properties"); + + Properties props = new Properties(); + props.load(config); + if (props.containsKey("basin_host")) { + /* + * TODO Site builder tests if service is working, which may be + * inefficient for single call services. + */ + URL url = new URL(props.getProperty("basin_host") + "/nshmp-site-ws/basin/local-data"); + this.basinUrl = url; + } + } catch (IOException | NullPointerException e) { + throw new ServletException(e); + } + } + + @Override + protected void doGet( + HttpServletRequest request, + HttpServletResponse response) + throws ServletException, IOException { + + UrlHelper urlHelper = urlHelper(request, response); + + if (emptyRequest(request)) { + urlHelper.writeResponse(USAGE); + return; + } + + try { + RequestData requestData = buildRequestData(request); + + /* Submit as task to job executor */ + Deagg2Task task = new Deagg2Task(urlHelper.url, getServletContext(), requestData); + Result result = ServletUtil.TASK_EXECUTOR.submit(task).get(); + GSON.toJson(result, response.getWriter()); + + } catch (Exception e) { + String message = Metadata.errorMessage(urlHelper.url, e, false); + response.getWriter().print(message); + getServletContext().log(urlHelper.url, e); + } + } + + /* Reduce query string key-value pairs. */ + static RequestData buildRequestData(HttpServletRequest request) { + + try { + + /* process query '?' request */ + List models = readModelsFromQuery(request); + double lon = readDouble(LONGITUDE, request); + double lat = readDouble(LATITUDE, request); + Map imtImls = readImtsFromQuery(request); + double vs30 = readDouble(VS30, request); + boolean basin = readBoolean(BASIN, request); + + return new RequestData( + models, + lon, + lat, + imtImls, + vs30, + basin); + + } catch (Exception e) { + throw new IllegalArgumentException("Error parsing request URL", e); + } + } + + private static List readModelsFromQuery(HttpServletRequest request) { + String[] ids = Util.readValues(MODEL, request); + return Arrays.stream(ids) + .map(Model::valueOf) + .distinct() + .collect(ImmutableList.toImmutableList()); + } + + /* Create map of IMT to deagg IML. */ + private static Map readImtsFromQuery(HttpServletRequest request) { + EnumMap imtImls = new EnumMap<>(Imt.class); + for (Entry param : request.getParameterMap().entrySet()) { + if (isImtParam(param.getKey())) { + imtImls.put( + Imt.valueOf(param.getKey()), + Double.valueOf(param.getValue()[0])); + } + } + return imtImls; + } + + private static boolean isImtParam(String key) { + return key.equals("PGA") || key.startsWith("SA"); + } + + private class Deagg2Task extends TimedTask { + + RequestData data; + + Deagg2Task(String url, ServletContext context, RequestData data) { + super(url, context); + this.data = data; + } + + @Override + Result calc() throws Exception { + Deaggregation deagg = calcDeagg(data); + + return new Result.Builder() + .requestData(data) + .url(url) + .timer(timer) + .deagg(deagg) + .build(); + } + } + + /* + * Developer notes: + * + * We're opting here to fetch basin terms ourselves. If we were to set the + * basin provider in the config, which requires additions to config, the URL + * is tested every time a site is created for a servlet request. While this + * worked for maps it's not good here. + * + * Site has logic for parsing the basin service response, which perhaps it + * shouldn't. TODO is it worth decomposing data objects and services + */ + Deaggregation calcDeagg(RequestData data) { + Location loc = Location.create(data.latitude, data.longitude); + + Site site = Site.builder() + .location(Location.create(data.latitude, data.longitude)) + .basinDataProvider(data.basin ? this.basinUrl : null) + .vs30(data.vs30) + .build(); + + Hazard[] hazards = new Hazard[data.models.size()]; + for (int i = 0; i < data.models.size(); i++) { + HazardModel model = modelCache.getUnchecked(data.models.get(i)); + hazards[i] = process(model, site, data.imtImls.keySet()); + } + Hazard hazard = Hazard.merge(hazards); + return Deaggregation.atImls(hazard, data.imtImls, ServletUtil.CALC_EXECUTOR); + } + + private static Hazard process(HazardModel model, Site site, Set imts) { + CalcConfig config = CalcConfig.Builder + .copyOf(model.config()) + .imts(imts) + .build(); + // System.out.println(config); + return HazardCalcs.hazard(model, config, site, ServletUtil.CALC_EXECUTOR); + } + + static final class RequestData { + + final List models; + final double latitude; + final double longitude; + final Map imtImls; + final double vs30; + final boolean basin; + + RequestData( + List models, + double longitude, + double latitude, + Map imtImls, + double vs30, + boolean basin) { + + this.models = models; + this.latitude = latitude; + this.longitude = longitude; + this.imtImls = imtImls; + this.vs30 = vs30; + this.basin = basin; + } + } + + private static final class ResponseData { + + final List models; + final double longitude; + final double latitude; + final String imt; + final double iml; + final double vs30; + final String rlabel = "Closest Distance, rRup (km)"; + final String mlabel = "Magnitude (Mw)"; + final String εlabel = "% Contribution to Hazard"; + final Object εbins; + + ResponseData(Deaggregation deagg, RequestData request, Imt imt) { + this.models = request.models; + this.longitude = request.longitude; + this.latitude = request.latitude; + this.imt = imt.toString(); + this.iml = request.imtImls.get(imt); + this.vs30 = request.vs30; + this.εbins = deagg.εBins(); + } + } + + private static final class Response { + + final ResponseData metadata; + final Object data; + + Response(ResponseData metadata, Object data) { + this.metadata = metadata; + this.data = data; + } + } + + private static final class Result { + + final String status = Status.SUCCESS.toString(); + final String date = ZonedDateTime.now().format(ServletUtil.DATE_FMT); + final String url; + final Object server; + final List response; + + Result(String url, Object server, List response) { + this.url = url; + this.server = server; + this.response = response; + } + + static final class Builder { + + String url; + Timer timer; + RequestData request; + Deaggregation deagg; + + Builder deagg(Deaggregation deagg) { + this.deagg = deagg; + return this; + } + + Builder url(String url) { + this.url = url; + return this; + } + + Builder timer(Timer timer) { + this.timer = timer; + return this; + } + + Builder requestData(RequestData request) { + this.request = request; + return this; + } + + Result build() { + ImmutableList.Builder responseListBuilder = ImmutableList.builder(); + + for (Imt imt : request.imtImls.keySet()) { + ResponseData responseData = new ResponseData(deagg, request, imt); + Object deaggs = deagg.toJsonCompact(imt); + Response response = new Response(responseData, deaggs); + responseListBuilder.add(response); + } + + List responseList = responseListBuilder.build(); + Object server = Metadata.serverData(ServletUtil.THREAD_COUNT, timer); + + return new Result(url, server, responseList); + } + } + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/DeaggService.java b/src/gov/usgs/earthquake/nshmp/www/DeaggService.java new file mode 100644 index 000000000..47420b95b --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/DeaggService.java @@ -0,0 +1,235 @@ +package gov.usgs.earthquake.nshmp.www; + +import static gov.usgs.earthquake.nshmp.www.ServletUtil.GSON; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.emptyRequest; + +import java.io.IOException; +import java.time.ZonedDateTime; +import java.util.List; + +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; + +import gov.usgs.earthquake.nshmp.calc.Deaggregation; +import gov.usgs.earthquake.nshmp.calc.Hazard; +import gov.usgs.earthquake.nshmp.calc.HazardCalcs; +import gov.usgs.earthquake.nshmp.calc.Vs30; +import gov.usgs.earthquake.nshmp.gmm.Imt; +import gov.usgs.earthquake.nshmp.internal.Parsing; +import gov.usgs.earthquake.nshmp.internal.Parsing.Delimiter; +import gov.usgs.earthquake.nshmp.www.HazardService.RequestData; +import gov.usgs.earthquake.nshmp.www.ServletUtil.TimedTask; +import gov.usgs.earthquake.nshmp.www.ServletUtil.Timer; +import gov.usgs.earthquake.nshmp.www.meta.Edition; +import gov.usgs.earthquake.nshmp.www.meta.Metadata; +import gov.usgs.earthquake.nshmp.www.meta.Region; +import gov.usgs.earthquake.nshmp.www.meta.Status; + +/** + * Hazard deaggregation service. + * + * @author Peter Powers + */ +@SuppressWarnings("unused") +@WebServlet( + name = "Deaggregation Service", + description = "USGS NSHMP Hazard Deaggregator", + urlPatterns = { + "/deagg", + "/deagg/*" }) +public final class DeaggService extends NshmpServlet { + + /* Developer notes: See HazardService. */ + + @Override + protected void doGet( + HttpServletRequest request, + HttpServletResponse response) + throws ServletException, IOException { + + UrlHelper urlHelper = urlHelper(request, response); + String query = request.getQueryString(); + String pathInfo = request.getPathInfo(); + + if (emptyRequest(request)) { + urlHelper.writeResponse(Metadata.DEAGG_USAGE); + return; + } + + if (ServletUtil.uhtBusy) { + ServletUtil.missCount++; + String message = Metadata.busyMessage( + urlHelper.url, + ServletUtil.hitCount, + ServletUtil.missCount); + //response.setStatus(503); + response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); + response.getWriter().print(message); + return; + } + + RequestData requestData; + ServletUtil.uhtBusy = true; + try { + if (query != null) { + /* process query '?' request */ + requestData = HazardService.buildRequest(request); + } else { + /* process slash-delimited request */ + List params = Parsing.splitToList(pathInfo, Delimiter.SLASH); + if (params.size() < 7) { + urlHelper.writeResponse(Metadata.DEAGG_USAGE); + return; + } + requestData = HazardService.buildRequest(params); + } + + /* Submit as task to job executor */ + DeaggTask task = new DeaggTask(urlHelper.url, getServletContext(), requestData); + Result result = ServletUtil.TASK_EXECUTOR.submit(task).get(); + String resultStr = GSON.toJson(result); + response.getWriter().print(resultStr); + + } catch (Exception e) { + String message = Metadata.errorMessage(urlHelper.url, e, false); + response.getWriter().print(message); + getServletContext().log(urlHelper.url, e); + } + ServletUtil.hitCount++; + ServletUtil.uhtBusy = false; + } + + private static class DeaggTask extends TimedTask { + + RequestData data; + + DeaggTask(String url, ServletContext context, RequestData data) { + super(url, context); + this.data = data; + } + + @Override + Result calc() throws Exception { + + Hazard hazard = HazardService.calcHazard(data, context); + Deaggregation deagg = HazardCalcs.deaggReturnPeriod( + hazard, + data.returnPeriod.getAsDouble(), + ServletUtil.CALC_EXECUTOR); + + return new Result.Builder() + .requestData(data) + .url(url) + .timer(timer) + .deagg(deagg) + .build(); + } + } + + private static final class ResponseData { + + final Edition edition; + final Region region; + final double latitude; + final double longitude; + final Imt imt; + final double returnperiod; + final Vs30 vs30; + final String rlabel = "Closest Distance, rRup (km)"; + final String mlabel = "Magnitude (Mw)"; + final String εlabel = "% Contribution to Hazard"; + final Object εbins; + + ResponseData(Deaggregation deagg, RequestData request, Imt imt) { + this.edition = request.edition; + this.region = request.region; + this.longitude = request.longitude; + this.latitude = request.latitude; + this.imt = imt; + this.returnperiod = request.returnPeriod.getAsDouble(); + this.vs30 = request.vs30; + this.εbins = deagg.εBins(); + } + } + + private static final class Response { + + final ResponseData metadata; + final Object data; + + Response(ResponseData metadata, Object data) { + this.metadata = metadata; + this.data = data; + } + } + + private static final String TOTAL_KEY = "Total"; + + private static final class Result { + + final String status = Status.SUCCESS.toString(); + final String date = ZonedDateTime.now().format(ServletUtil.DATE_FMT); + final String url; + final Object server; + final List response; + + Result(String url, Object server, List response) { + this.url = url; + this.server = server; + this.response = response; + } + + static final class Builder { + + String url; + Timer timer; + RequestData request; + Deaggregation deagg; + + Builder deagg(Deaggregation deagg) { + this.deagg = deagg; + return this; + } + + Builder url(String url) { + this.url = url; + return this; + } + + Builder timer(Timer timer) { + this.timer = timer; + return this; + } + + Builder requestData(RequestData request) { + this.request = request; + return this; + } + + Result build() { + + ImmutableList.Builder responseListBuilder = ImmutableList.builder(); + for (Imt imt : request.imts) { + ResponseData responseData = new ResponseData( + deagg, + request, + imt); + Object deaggs = deagg.toJson(imt); + Response response = new Response(responseData, deaggs); + responseListBuilder.add(response); + } + List responseList = responseListBuilder.build(); + Object server = Metadata.serverData(ServletUtil.THREAD_COUNT, timer); + + return new Result(url, server, responseList); + } + } + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/DeaggService2.java b/src/gov/usgs/earthquake/nshmp/www/DeaggService2.java new file mode 100644 index 000000000..94deb23b0 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/DeaggService2.java @@ -0,0 +1,384 @@ +package gov.usgs.earthquake.nshmp.www; + +import static com.google.common.base.Preconditions.checkNotNull; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.GSON; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.MODEL_CACHE_CONTEXT_ID; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.emptyRequest; +import static gov.usgs.earthquake.nshmp.www.Util.readBoolean; +import static gov.usgs.earthquake.nshmp.www.Util.readDouble; +import static gov.usgs.earthquake.nshmp.www.Util.readValue; +import static gov.usgs.earthquake.nshmp.www.Util.Key.BASIN; +import static gov.usgs.earthquake.nshmp.www.Util.Key.IMT; +import static gov.usgs.earthquake.nshmp.www.Util.Key.LATITUDE; +import static gov.usgs.earthquake.nshmp.www.Util.Key.LONGITUDE; +import static gov.usgs.earthquake.nshmp.www.Util.Key.MODEL; +import static gov.usgs.earthquake.nshmp.www.Util.Key.RETURNPERIOD; +import static gov.usgs.earthquake.nshmp.www.Util.Key.VS30; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Optional; +import java.util.Properties; + +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableList; + +import gov.usgs.earthquake.nshmp.calc.CalcConfig; +import gov.usgs.earthquake.nshmp.calc.Deaggregation; +import gov.usgs.earthquake.nshmp.calc.Hazard; +import gov.usgs.earthquake.nshmp.calc.HazardCalcs; +import gov.usgs.earthquake.nshmp.calc.Site; +import gov.usgs.earthquake.nshmp.eq.model.HazardModel; +import gov.usgs.earthquake.nshmp.geo.Location; +import gov.usgs.earthquake.nshmp.gmm.Imt; +import gov.usgs.earthquake.nshmp.internal.Parsing; +import gov.usgs.earthquake.nshmp.internal.Parsing.Delimiter; +import gov.usgs.earthquake.nshmp.www.ServletUtil.TimedTask; +import gov.usgs.earthquake.nshmp.www.ServletUtil.Timer; +import gov.usgs.earthquake.nshmp.www.meta.Metadata; +import gov.usgs.earthquake.nshmp.www.meta.Status; + +/** + * Hazard deaggregation service. + * + * @author Peter Powers + */ +@Deprecated +@SuppressWarnings("unused") +@WebServlet( + name = "Deaggregation Service (new)", + description = "USGS NSHMP Hazard Deaggregator", + urlPatterns = { + "/deagg2", + "/deagg2/*" }) +public final class DeaggService2 extends NshmpServlet { + + /* Developer notes: See HazardService. */ + + private LoadingCache modelCache; + private URL basinUrl; + + private static final String USAGE = SourceServices.GSON.toJson( + new SourceServices.ResponseData()); + + @Override + @SuppressWarnings("unchecked") + public void init() throws ServletException { + + ServletContext context = getServletConfig().getServletContext(); + Object modelCache = context.getAttribute(MODEL_CACHE_CONTEXT_ID); + this.modelCache = (LoadingCache) modelCache; + + try (InputStream config = + DeaggService2.class.getResourceAsStream("/config.properties")) { + + checkNotNull(config, "Missing config.properties"); + + Properties props = new Properties(); + props.load(config); + if (props.containsKey("basin_host")) { + /* + * TODO Site builder tests if service is working, which may be + * inefficient for single call services. + */ + URL url = new URL(props.getProperty("basin_host") + "/nshmp-site-ws/basin"); + this.basinUrl = url; + } + } catch (IOException | NullPointerException e) { + throw new ServletException(e); + } + } + + @Override + protected void doGet( + HttpServletRequest request, + HttpServletResponse response) + throws ServletException, IOException { + + UrlHelper urlHelper = urlHelper(request, response); + + if (emptyRequest(request)) { + urlHelper.writeResponse(USAGE); + return; + } + + try { + RequestData requestData = buildRequestData(request); + + /* Submit as task to job executor */ + Deagg2Task task = new Deagg2Task(urlHelper.url, getServletContext(), requestData); + Result result = ServletUtil.TASK_EXECUTOR.submit(task).get(); + GSON.toJson(result, response.getWriter()); + + } catch (Exception e) { + String message = Metadata.errorMessage(urlHelper.url, e, false); + response.getWriter().print(message); + getServletContext().log(urlHelper.url, e); + } + } + + /* Reduce query string key-value pairs. */ + static RequestData buildRequestData(HttpServletRequest request) { + + try { + + List models; + double lon; + double lat; + Imt imt; + double vs30; + double returnPeriod; + boolean basin; + + if (request.getQueryString() != null) { + /* process query '?' request */ + models = readModelsFromQuery(request); + lon = readDouble(LONGITUDE, request); + lat = readDouble(LATITUDE, request); + imt = readValue(IMT, request, Imt.class); + vs30 = readDouble(VS30, request); + returnPeriod = readDouble(RETURNPERIOD, request); + basin = readBoolean(BASIN, request); + + } else { + /* process slash-delimited request */ + List params = Parsing.splitToList( + request.getPathInfo(), + Delimiter.SLASH); + models = readModelsFromString(params.get(0)); + lon = Double.valueOf(params.get(1)); + lat = Double.valueOf(params.get(2)); + imt = Imt.valueOf(params.get(3)); + vs30 = Double.valueOf(params.get(4)); + returnPeriod = Double.valueOf(params.get(5)); + basin = Boolean.valueOf(params.get(6)); + } + + return new RequestData( + models, + lon, + lat, + imt, + vs30, + returnPeriod, + basin); + + } catch (Exception e) { + throw new IllegalArgumentException("Error parsing request URL", e); + } + } + + private static List readModelsFromString(String models) { + return Parsing.splitToList(models, Delimiter.COMMA).stream() + .map(Model::valueOf) + .distinct() + .collect(ImmutableList.toImmutableList()); + } + + private static List readModelsFromQuery(HttpServletRequest request) { + String[] ids = Util.readValues(MODEL, request); + return Arrays.stream(ids) + .map(Model::valueOf) + .distinct() + .collect(ImmutableList.toImmutableList()); + } + + private class Deagg2Task extends TimedTask { + + RequestData data; + + Deagg2Task(String url, ServletContext context, RequestData data) { + super(url, context); + this.data = data; + } + + @Override + Result calc() throws Exception { + Deaggregation deagg = calcDeagg(data); + + return new Result.Builder() + .requestData(data) + .url(url) + .timer(timer) + .deagg(deagg) + .build(); + } + } + + /* + * Developer notes: + * + * We're opting here to fetch basin terms ourselves. If we were to set the + * basin provider in the config, which requires additions to config, the URL + * is tested every time a site is created for a servlet request. While this + * worked for maps it's not good here. + * + * Site has logic for parsing the basin service response, which perhaps it + * shouldn't. TODO is it worth decomposing data objects and services + */ + Deaggregation calcDeagg(RequestData data) { + Location loc = Location.create(data.latitude, data.longitude); + + Site site = Site.builder() + .location(Location.create(data.latitude, data.longitude)) + .basinDataProvider(data.basin ? this.basinUrl : null) + .vs30(data.vs30) + .build(); + + Hazard[] hazards = new Hazard[data.models.size()]; + for (int i = 0; i < data.models.size(); i++) { + HazardModel model = modelCache.getUnchecked(data.models.get(i)); + hazards[i] = process(model, site, data.imt); + } + Hazard hazard = Hazard.merge(hazards); + return HazardCalcs.deaggReturnPeriod( + hazard, + data.returnPeriod, + ServletUtil.CALC_EXECUTOR); + } + + private static Hazard process(HazardModel model, Site site, Imt imt) { + CalcConfig config = CalcConfig.Builder + .copyOf(model.config()) + .imts(EnumSet.of(imt)) + .build(); + return HazardCalcs.hazard(model, config, site, ServletUtil.CALC_EXECUTOR); + } + + static final class RequestData { + + final List models; + final double latitude; + final double longitude; + final Imt imt; + final double vs30; + final double returnPeriod; + final boolean basin; + + RequestData( + List models, + double longitude, + double latitude, + Imt imt, + double vs30, + double returnPeriod, + boolean basin) { + + this.models = models; + this.latitude = latitude; + this.longitude = longitude; + this.imt = imt; + this.vs30 = vs30; + this.returnPeriod = returnPeriod; + this.basin = basin; + } + } + + private static final class ResponseData { + + final List models; + final double longitude; + final double latitude; + final Imt imt; + final double vs30; + final double returnperiod; + final String rlabel = "Closest Distance, rRup (km)"; + final String mlabel = "Magnitude (Mw)"; + final String εlabel = "% Contribution to Hazard"; + final Object εbins; + + ResponseData(Deaggregation deagg, RequestData request, Imt imt) { + this.models = request.models; + this.longitude = request.longitude; + this.latitude = request.latitude; + this.imt = imt; + this.vs30 = request.vs30; + this.returnperiod = request.returnPeriod; + this.εbins = deagg.εBins(); + } + } + + private static final class Response { + + final ResponseData metadata; + final Object data; + + Response(ResponseData metadata, Object data) { + this.metadata = metadata; + this.data = data; + } + } + + private static final class Result { + + final String status = Status.SUCCESS.toString(); + final String date = ZonedDateTime.now().format(ServletUtil.DATE_FMT); + final String url; + final Object server; + final List response; + + Result(String url, Object server, List response) { + this.url = url; + this.server = server; + this.response = response; + } + + static final class Builder { + + String url; + Timer timer; + RequestData request; + Deaggregation deagg; + + Builder deagg(Deaggregation deagg) { + this.deagg = deagg; + return this; + } + + Builder url(String url) { + this.url = url; + return this; + } + + Builder timer(Timer timer) { + this.timer = timer; + return this; + } + + Builder requestData(RequestData request) { + this.request = request; + return this; + } + + Result build() { + + ImmutableList.Builder responseListBuilder = ImmutableList.builder(); + Imt imt = request.imt; + ResponseData responseData = new ResponseData( + deagg, + request, + imt); + Object deaggs = deagg.toJson(imt); + Response response = new Response(responseData, deaggs); + responseListBuilder.add(response); + + List responseList = responseListBuilder.build(); + Object server = Metadata.serverData(ServletUtil.THREAD_COUNT, timer); + + return new Result(url, server, responseList); + } + } + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/GmmServices.java b/src/gov/usgs/earthquake/nshmp/www/GmmServices.java new file mode 100644 index 000000000..6a902e84e --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/GmmServices.java @@ -0,0 +1,701 @@ +package gov.usgs.earthquake.nshmp.www; + +import static com.google.common.base.Preconditions.checkArgument; +import static gov.usgs.earthquake.nshmp.ResponseSpectra.spectra; +import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.VSINF; +import static gov.usgs.earthquake.nshmp.gmm.Imt.AI; +import static gov.usgs.earthquake.nshmp.gmm.Imt.PGV; +import static gov.usgs.earthquake.nshmp.www.Util.readValue; +import static gov.usgs.earthquake.nshmp.www.Util.Key.IMT; +import static gov.usgs.earthquake.nshmp.www.meta.Metadata.errorMessage; + +import java.io.BufferedReader; +import java.io.IOException; +import java.lang.reflect.Type; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.google.common.base.Enums; +import com.google.common.collect.FluentIterable; +import com.google.common.collect.Range; +import com.google.common.collect.Sets; +import com.google.common.primitives.Doubles; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import gov.usgs.earthquake.nshmp.GroundMotions; +import gov.usgs.earthquake.nshmp.GroundMotions.DistanceResult; +import gov.usgs.earthquake.nshmp.ResponseSpectra.MultiResult; +import gov.usgs.earthquake.nshmp.data.Data; +import gov.usgs.earthquake.nshmp.data.XySequence; +import gov.usgs.earthquake.nshmp.gmm.Gmm; +import gov.usgs.earthquake.nshmp.gmm.GmmInput; +import gov.usgs.earthquake.nshmp.gmm.GmmInput.Builder; +import gov.usgs.earthquake.nshmp.gmm.GmmInput.Constraints; +import gov.usgs.earthquake.nshmp.gmm.GmmInput.Field; +import gov.usgs.earthquake.nshmp.gmm.Imt; +import gov.usgs.earthquake.nshmp.internal.Parsing; +import gov.usgs.earthquake.nshmp.internal.Parsing.Delimiter; +import gov.usgs.earthquake.nshmp.www.meta.EnumParameter; +import gov.usgs.earthquake.nshmp.www.meta.ParamType; +import gov.usgs.earthquake.nshmp.www.meta.Status; +import gov.usgs.earthquake.nshmp.www.meta.Util; + +@WebServlet( + name = "Ground Motion Model Services", + description = "Utilities for working with ground motion models", + urlPatterns = { + "/gmm", + "/gmm/*" }) +public class GmmServices extends NshmpServlet { + private static final long serialVersionUID = 1L; + + private static final Gson GSON; + + private static final String GMM_KEY = "gmm"; + private static final String RMIN_KEY = "rMin"; + private static final String RMAX_KEY = "rMax"; + private static final String IMT_KEY = "imt"; + private static final int ROUND = 5; + + static { + GSON = new GsonBuilder() + .setPrettyPrinting() + .serializeNulls() + .disableHtmlEscaping() + .registerTypeAdapter(Double.class, new Util.NaNSerializer()) + .registerTypeAdapter(Parameters.class, new Parameters.Serializer()) + .registerTypeAdapter(Imt.class, new Util.EnumSerializer()) + .registerTypeAdapter(Constraints.class, new Util.ConstraintsSerializer()) + .create(); + } + + @Override + protected void doGet( + HttpServletRequest request, + HttpServletResponse response) + throws ServletException, IOException { + + UrlHelper urlHelper = urlHelper(request, response); + Service service = getService(request); + + try { + /* At a minimum, Gmms must be defined. */ + if (!hasGMM(request, service, urlHelper)) return; + + Map params = request.getParameterMap(); + + ResponseData svcResponse = processRequest(service, params, urlHelper); + + response.getWriter().print(GSON.toJson(svcResponse)); + } catch (Exception e) { + String message = errorMessage(urlHelper.url, e, false); + response.getWriter().print(message); + e.printStackTrace(); + } + } + + @Override + protected void doPost( + HttpServletRequest request, + HttpServletResponse response) + throws ServletException, IOException { + + BufferedReader requestReader = request.getReader(); + UrlHelper urlHelper = urlHelper(request, response); + Service service = getService(request); + + try { + /* At a minimum, Gmms must be defined. */ + if (!hasGMM(request, service, urlHelper)) return; + + String[] gmmParams = request.getParameterValues(GMM_KEY); + + List requestData = requestReader.lines().collect(Collectors.toList()); + + if (requestData.isEmpty()) { + throw new IllegalStateException("Post data is empty"); + } + + List keys = Parsing.splitToList(requestData.get(0), Delimiter.COMMA); + + ResponseDataPost svcResponse = new ResponseDataPost(service, urlHelper); + + List gmmResponses = requestData.subList(1, requestData.size()) + .parallelStream() + .filter((line) -> !line.startsWith("#") && !line.trim().isEmpty()) + .map((line) -> { + List values = Parsing.splitToList(line, Delimiter.COMMA); + + Map params = new HashMap<>(); + params.put(GMM_KEY, gmmParams); + + int index = 0; + + for (String key : keys) { + String value = values.get(index); + if ("null".equals(value.toLowerCase())) continue; + + params.put(key, new String[] { value }); + index++; + } + + return processRequest(service, params, urlHelper); + }) + .collect(Collectors.toList()); + + svcResponse.setResponse(gmmResponses); + response.getWriter().print(GSON.toJson(svcResponse)); + } catch (Exception e) { + String message = errorMessage(urlHelper.url, e, false); + response.getWriter().print(message); + e.printStackTrace(); + } + } + + static class RequestData { + Set gmms; + GmmInput input; + + RequestData(Map params) { + this.gmms = buildGmmSet(params); + this.input = buildInput(params); + } + } + + static class RequestDataDistance extends RequestData { + String imt; + double minDistance; + double maxDistance; + + RequestDataDistance( + Map params, + String imt, + double rMin, + double rMax) { + + super(params); + + this.imt = imt; + minDistance = rMin; + maxDistance = rMax; + } + } + + static class ResponseDataPost { + String name; + String status = Status.SUCCESS.toString(); + String date = ZonedDateTime.now().format(ServletUtil.DATE_FMT); + String url; + Object server; + List response; + + ResponseDataPost(Service service, UrlHelper urlHelper) { + name = service.resultName; + + server = gov.usgs.earthquake.nshmp.www.meta.Metadata.serverData(1, ServletUtil.timer()); + + url = urlHelper.url; + } + + void setResponse(List response) { + this.response = response; + } + } + + static class ResponseData { + String name; + String status = Status.SUCCESS.toString(); + String date = ZonedDateTime.now().format(ServletUtil.DATE_FMT); + String url; + Object server; + RequestData request; + GmmXYDataGroup means; + GmmXYDataGroup sigmas; + + ResponseData(Service service, RequestData request) { + name = service.resultName; + + server = gov.usgs.earthquake.nshmp.www.meta.Metadata.serverData(1, ServletUtil.timer()); + + this.request = request; + + means = GmmXYDataGroup.create( + service.groupNameMean, + service.xLabel, + service.yLabelMedian); + + sigmas = GmmXYDataGroup.create( + service.groupNameSigma, + service.xLabel, + service.yLabelSigma); + } + + void setXY( + Map> x, + Map> means, + Map> sigmas) { + + for (Gmm gmm : means.keySet()) { + XySequence xyMeans = XySequence.create( + x.get(gmm), + Data.round(ROUND, Data.exp(new ArrayList<>(means.get(gmm))))); + this.means.add(gmm.name(), gmm.toString(), xyMeans, gmm); + + XySequence xySigmas = XySequence.create( + x.get(gmm), + Data.round(ROUND, new ArrayList<>(sigmas.get(gmm)))); + this.sigmas.add(gmm.name(), gmm.toString(), xySigmas, gmm); + } + } + + } + + private static class GmmXYDataGroup extends XY_DataGroup { + + GmmXYDataGroup(String name, String xLabel, String yLabel) { + super(name, xLabel, yLabel); + } + + public static GmmXYDataGroup create(String name, String xLabel, String yLabel) { + return new GmmXYDataGroup(name, xLabel, yLabel); + } + + public GmmXYDataGroup add(String id, String name, XySequence data, Gmm gmm) { + this.data.add(new GmmSeries(id, name, data, gmm)); + return this; + } + + static class GmmSeries extends XY_DataGroup.Series { + final Constraints constraints; + final TreeSet supportedImts; + + GmmSeries(String id, String label, XySequence data, Gmm gmm) { + super(id, label, data); + constraints = gmm.constraints(); + supportedImts = gmm.supportedIMTs().stream() + .map(imt -> imt.name()) + .collect(Collectors.toCollection(TreeSet::new)); + } + } + } + + static ResponseData processRequest( + Service service, + Map params, + UrlHelper urlHelper) { + ResponseData svcResponse = null; + + switch (service) { + case DISTANCE: + case HW_FW: + svcResponse = processRequestDistance(service, params); + break; + case SPECTRA: + svcResponse = processRequestSpectra(service, params); + break; + default: + throw new IllegalStateException("Service not supported [" + service + "]"); + } + + svcResponse.url = urlHelper.url; + return svcResponse; + } + + static ResponseData processRequestDistance( + Service service, Map params) { + + boolean isLogSpace = service.equals(Service.DISTANCE) ? true : false; + Imt imt = readValue(IMT, params, Imt.class); + double rMin = Double.valueOf(params.get(RMIN_KEY)[0]); + double rMax = Double.valueOf(params.get(RMAX_KEY)[0]); + + RequestDataDistance request = new RequestDataDistance( + params, imt.toString(), rMin, rMax); + + DistanceResult result = GroundMotions.distanceGroundMotions( + request.gmms, request.input, imt, rMin, rMax, isLogSpace); + + ResponseData response = new ResponseData(service, request); + response.setXY(result.distance, result.means, result.sigmas); + + return response; + } + + private static ResponseData processRequestSpectra( + Service service, Map params) { + + RequestData request = new RequestData(params); + MultiResult result = spectra(request.gmms, request.input, false); + + ResponseData response = new ResponseData(service, request); + response.setXY(result.periods, result.means, result.sigmas); + + return response; + } + + static Set buildGmmSet(Map params) { + checkArgument(params.containsKey(GMM_KEY), + "Missing ground motion model key: " + GMM_KEY); + return Sets.newEnumSet( + FluentIterable + .from(params.get(GMM_KEY)) + .transform(Enums.stringConverter(Gmm.class)), + Gmm.class); + } + + static GmmInput buildInput(Map params) { + + Builder builder = GmmInput.builder().withDefaults(); + for (Entry entry : params.entrySet()) { + if (entry.getKey().equals(GMM_KEY) || entry.getKey().equals(IMT_KEY) || + entry.getKey().equals(RMAX_KEY) || entry.getKey().equals(RMIN_KEY)) + continue; + Field id = Field.fromString(entry.getKey()); + String value = entry.getValue()[0]; + if (value.equals("")) { + continue; + } + builder.set(id, value); + } + return builder.build(); + } + + static final class Metadata { + + String status = Status.USAGE.toString(); + String description; + String syntax; + Parameters parameters; + + Metadata(Service service) { + this.syntax = "%s://%s/nshmp-haz-ws/gmm" + service.pathInfo + "?"; + this.description = service.description; + this.parameters = new Parameters(service); + } + } + + /* + * Placeholder class; all parameter serialization is done via the custom + * Serializer. Service reference needed serialize(). + */ + static final class Parameters { + + private final Service service; + + Parameters(Service service) { + this.service = service; + } + + static final class Serializer implements JsonSerializer { + + @Override + public JsonElement serialize( + Parameters meta, + Type type, + JsonSerializationContext context) { + + JsonObject root = new JsonObject(); + + if (!meta.service.equals(Service.SPECTRA)) { + Set imtSet = EnumSet.complementOf(EnumSet.range(PGV, AI)); + final EnumParameter imts; + imts = new EnumParameter<>( + "Intensity measure type", + ParamType.STRING, + imtSet); + root.add(IMT_KEY, context.serialize(imts)); + } + + /* Serialize input fields. */ + Constraints defaults = Constraints.defaults(); + for (Field field : Field.values()) { + Param param = createGmmInputParam(field, defaults.get(field)); + JsonElement fieldElem = context.serialize(param); + root.add(field.id, fieldElem); + } + + /* Add only add those Gmms that belong to a Group. */ + List gmms = Arrays.stream(Gmm.Group.values()) + .flatMap(group -> group.gmms().stream()) + .sorted(Comparator.comparing(Object::toString)) + .distinct() + .collect(Collectors.toList()); + + GmmParam gmmParam = new GmmParam( + GMM_NAME, + GMM_INFO, + gmms); + root.add(GMM_KEY, context.serialize(gmmParam)); + + /* Add gmm groups. */ + GroupParam groups = new GroupParam( + GROUP_NAME, + GROUP_INFO, + EnumSet.allOf(Gmm.Group.class)); + root.add(GROUP_KEY, context.serialize(groups)); + + return root; + } + } + } + + @SuppressWarnings("unchecked") + private static Param createGmmInputParam( + Field field, + Optional constraint) { + return (field == VSINF) ? new BooleanParam(field) + : new NumberParam(field, (Range) constraint.get()); + } + + /* + * Marker interface for spectra parameters. This was previously implemented as + * an abstract class for label, info, and units, but Gson serialized subclass + * fields before parent fields. To maintain a preferred order, one can write + * custom serializers or repeat these four fields in each implementation. + */ + private static interface Param {} + + @SuppressWarnings("unused") + private static final class NumberParam implements Param { + + final String label; + final String info; + final String units; + final Double min; + final Double max; + final Double value; + + NumberParam(GmmInput.Field field, Range constraint) { + this(field, constraint, field.defaultValue); + } + + NumberParam(GmmInput.Field field, Range constraint, Double value) { + this.label = field.label; + this.info = field.info; + this.units = field.units.orElse(null); + this.min = constraint.lowerEndpoint(); + this.max = constraint.upperEndpoint(); + this.value = Doubles.isFinite(value) ? value : null; + } + } + + @SuppressWarnings("unused") + private static final class BooleanParam implements Param { + + final String label; + final String info; + final boolean value; + + BooleanParam(GmmInput.Field field) { + this(field, field.defaultValue == 1.0); + } + + BooleanParam(GmmInput.Field field, boolean value) { + this.label = field.label; + this.info = field.info; + this.value = value; + } + } + + private static final String GMM_NAME = "Ground Motion Models"; + private static final String GMM_INFO = "Empirical models of ground motion"; + + @SuppressWarnings("unused") + private static class GmmParam implements Param { + + final String label; + final String info; + final List values; + + GmmParam(String label, String info, List gmms) { + this.label = label; + this.info = info; + this.values = gmms.stream() + .map(gmm -> new Value(gmm)) + .collect(Collectors.toList()); + } + + private static class Value { + + final String id; + final String label; + final ArrayList supportedImts; + final Constraints constraints; + + Value(Gmm gmm) { + this.id = gmm.name(); + this.label = gmm.toString(); + this.supportedImts = SupportedImts(gmm.supportedIMTs()); + this.constraints = gmm.constraints(); + } + } + + private static ArrayList SupportedImts(Set imts) { + ArrayList supportedImts = new ArrayList<>(); + + for (Imt imt : imts) { + supportedImts.add(imt.name()); + } + + return supportedImts; + } + + } + + private static final String GROUP_KEY = "group"; + private static final String GROUP_NAME = "Ground Motion Model Groups"; + private static final String GROUP_INFO = "Groups of related ground motion models "; + + @SuppressWarnings("unused") + private static final class GroupParam implements Param { + + final String label; + final String info; + final List values; + + GroupParam(String label, String info, Set groups) { + this.label = label; + this.info = info; + this.values = new ArrayList<>(); + for (Gmm.Group group : groups) { + this.values.add(new Value(group)); + } + } + + private static class Value { + + final String id; + final String label; + final List data; + + Value(Gmm.Group group) { + this.id = group.name(); + this.label = group.toString(); + this.data = group.gmms(); + } + } + } + + private static enum Service { + + DISTANCE( + "Ground Motion Vs. Distance", + "Compute ground motion Vs. distance", + "/distance", + "Means", + "Sigmas", + "Distance (km)", + "Median ground motion (g)", + "Standard deviation"), + + HW_FW( + "Hanging Wall Effect", + "Compute hanging wall effect on ground motion Vs. distance", + "/hw-fw", + "Means", + "Sigmas", + "Distance (km)", + "Median ground motion (g)", + "Standard deviation"), + + SPECTRA( + "Deterministic Response Spectra", + "Compute deterministic response spectra", + "/spectra", + "Means", + "Sigmas", + "Period (s)", + "Median ground motion (g)", + "Standard deviation"); + + final String name; + final String description; + final String pathInfo; + final String resultName; + final String groupNameMean; + final String groupNameSigma; + final String xLabel; + final String yLabelMedian; + final String yLabelSigma; + + private Service( + String name, String description, + String pathInfo, String groupNameMean, + String groupNameSigma, String xLabel, + String yLabelMedian, String yLabelSigma) { + this.name = name; + this.description = description; + this.resultName = name + " Results"; + this.pathInfo = pathInfo; + this.groupNameMean = groupNameMean; + this.groupNameSigma = groupNameSigma; + this.xLabel = xLabel; + this.yLabelMedian = yLabelMedian; + this.yLabelSigma = yLabelSigma; + } + + } + + private static Service getService(HttpServletRequest request) { + Service service = null; + String pathInfo = request.getPathInfo(); + + switch (pathInfo) { + case PathInfo.DISTANCE: + service = Service.DISTANCE; + break; + case PathInfo.HW_FW: + service = Service.HW_FW; + break; + case PathInfo.SPECTRA: + service = Service.SPECTRA; + break; + default: + throw new IllegalStateException("Unsupported service [" + pathInfo + "]"); + } + + return service; + } + + private static final class PathInfo { + private static final String DISTANCE = "/distance"; + private static final String HW_FW = "/hw-fw"; + private static final String SPECTRA = "/spectra"; + } + + private static boolean hasGMM( + HttpServletRequest request, + Service service, + UrlHelper urlHelper) + throws IOException { + + String gmmParam = request.getParameter(GMM_KEY); + if (gmmParam != null) return true; + + String usage = GSON.toJson(new Metadata(service)); + urlHelper.writeResponse(usage); + return false; + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/HazardService.java b/src/gov/usgs/earthquake/nshmp/www/HazardService.java new file mode 100644 index 000000000..242610037 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/HazardService.java @@ -0,0 +1,526 @@ +package gov.usgs.earthquake.nshmp.www; + +import static com.google.common.base.Preconditions.checkState; +import static gov.usgs.earthquake.nshmp.calc.HazardExport.curvesBySource; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.GSON; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.MODEL_CACHE_CONTEXT_ID; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.emptyRequest; +import static gov.usgs.earthquake.nshmp.www.Util.readDouble; +import static gov.usgs.earthquake.nshmp.www.Util.readValue; +import static gov.usgs.earthquake.nshmp.www.Util.readValues; +import static gov.usgs.earthquake.nshmp.www.Util.Key.EDITION; +import static gov.usgs.earthquake.nshmp.www.Util.Key.IMT; +import static gov.usgs.earthquake.nshmp.www.Util.Key.LATITUDE; +import static gov.usgs.earthquake.nshmp.www.Util.Key.LONGITUDE; +import static gov.usgs.earthquake.nshmp.www.Util.Key.REGION; +import static gov.usgs.earthquake.nshmp.www.Util.Key.RETURNPERIOD; +import static gov.usgs.earthquake.nshmp.www.Util.Key.VS30; +import static gov.usgs.earthquake.nshmp.www.meta.Region.CEUS; +import static gov.usgs.earthquake.nshmp.www.meta.Region.COUS; +import static gov.usgs.earthquake.nshmp.www.meta.Region.WUS; + +import java.io.IOException; +import java.time.ZonedDateTime; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalDouble; +import java.util.Set; + +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Sets; + +import gov.usgs.earthquake.nshmp.calc.CalcConfig; +import gov.usgs.earthquake.nshmp.calc.CalcConfig.Builder; +import gov.usgs.earthquake.nshmp.calc.Hazard; +import gov.usgs.earthquake.nshmp.calc.HazardCalcs; +import gov.usgs.earthquake.nshmp.calc.Site; +import gov.usgs.earthquake.nshmp.calc.Vs30; +import gov.usgs.earthquake.nshmp.data.XySequence; +import gov.usgs.earthquake.nshmp.eq.model.HazardModel; +import gov.usgs.earthquake.nshmp.eq.model.SourceType; +import gov.usgs.earthquake.nshmp.geo.Location; +import gov.usgs.earthquake.nshmp.gmm.Imt; +import gov.usgs.earthquake.nshmp.internal.Parsing; +import gov.usgs.earthquake.nshmp.internal.Parsing.Delimiter; +import gov.usgs.earthquake.nshmp.www.ServletUtil.TimedTask; +import gov.usgs.earthquake.nshmp.www.ServletUtil.Timer; +import gov.usgs.earthquake.nshmp.www.meta.Edition; +import gov.usgs.earthquake.nshmp.www.meta.Metadata; +import gov.usgs.earthquake.nshmp.www.meta.Region; +import gov.usgs.earthquake.nshmp.www.meta.Status; + +/** + * Probabilisitic seismic hazard calculation service. + * + * @author Peter Powers + */ +@SuppressWarnings("unused") +@WebServlet( + name = "Hazard Service", + description = "USGS NSHMP Hazard Curve Calculator", + urlPatterns = { + "/hazard", + "/hazard/*" }) +public final class HazardService extends NshmpServlet { + + /* + * Developer notes: + * + * The HazardService and DeaggService are very similar. Deagg delegates to a + * package method HazardService.hazardCalc() to obtain a Hazard object, which + * it then deaggregates. This method may combine Hazard objects from CEUS and + * WUS models, otherwise it runs a single model. HazardService.RequestData + * objects are common to both services, with the understanding that Optional + * fields (1) 'imts' will always contain a Set with a single entry for + * deagg, and that (2) 'returnPeriod' will be absent for hazard. + * + * Nshmp-haz calculations are designed to leverage all available processors by + * default distributing work using the ServletUtil.CALC_EXECUTOR. This can + * create problems in a servlet environment, however, because Tomcat does not + * support a single threaded request queue where requests are processed as + * they are received with the next task starting only once the prior has + * finished. One can really only limit the maximum number of simultaneous + * requests. When multiple requests are received in a short span, Tomcat will + * attempt to run hazard or deagg calculations simultaneously. The net effect + * is that there can be out of memory problems as too many results are + * retained, and multiple requests do not return until all are finished. + * + * To address this, requests of HazardService and DeaggService are submitted + * as tasks to the single-threaded ServletUtil.TASK_EXECUTOR and are processed + * one-at-a-time in the order received. + */ + + /* + * IMTs: PGA, SA0P20, SA1P00 TODO this need to be updated to the result of + * polling all models and supports needs to be updated to specific models + * + * Regions: COUS, WUS, CEUS, [HI, AK, GM, AS, SAM, ...] + * + * vs30: 180, 259, 360, 537, 760, 1150, 2000 + * + * 2014 updated values + * + * vs30: 185, 260, 365, 530, 760, 1080, 2000 + * + */ + + @Override + protected void doGet( + HttpServletRequest request, + HttpServletResponse response) + throws ServletException, IOException { + + UrlHelper urlHelper = urlHelper(request, response); + String query = request.getQueryString(); + String pathInfo = request.getPathInfo(); + + if (emptyRequest(request)) { + urlHelper.writeResponse(Metadata.HAZARD_USAGE); + return; + } + + if (ServletUtil.uhtBusy) { + ServletUtil.missCount++; + String message = Metadata.busyMessage( + urlHelper.url, + ServletUtil.hitCount, + ServletUtil.missCount); + //response.setStatus(503); + response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); + response.getWriter().print(message); + return; + } + + RequestData requestData; + ServletUtil.uhtBusy = true; + try { + if (query != null) { + /* process query '?' request */ + requestData = buildRequest(request); + } else { + /* process slash-delimited request */ + List params = Parsing.splitToList(pathInfo, Delimiter.SLASH); + if (params.size() < 6) { + urlHelper.writeResponse(Metadata.HAZARD_USAGE); + return; + } + requestData = buildRequest(params); + } + + /* Submit as task to job executor */ + HazardTask task = new HazardTask(urlHelper.url, getServletContext(), requestData); + Result result = ServletUtil.TASK_EXECUTOR.submit(task).get(); + // GSON.toJson(result, response.getWriter()); TODO test and use elsewhere? + String resultStr = GSON.toJson(result); + response.getWriter().print(resultStr); + + } catch (Exception e) { + String message = Metadata.errorMessage(urlHelper.url, e, false); + response.getWriter().print(message); + getServletContext().log(urlHelper.url, e); + } + ServletUtil.hitCount++; + ServletUtil.uhtBusy = false; + } + + /* + * Reduce query string key-value pairs. This method is shared with deagg. + * Deagg must supply a single Imt. See RequestData notes below. + */ + static RequestData buildRequest(HttpServletRequest request) { + + Map paramMap = request.getParameterMap(); + + /* Read params as for hazard. */ + double lon = readDouble(LONGITUDE, request); + double lat = readDouble(LATITUDE, request); + Vs30 vs30 = Vs30.fromValue(readDouble(VS30, request)); + Edition edition = readValue(EDITION, request, Edition.class); + Region region = ServletUtil.checkRegion( + readValue(REGION, request, Region.class), + lon); + Set supportedImts = Metadata.commonImts(edition, region); + Set imts = paramMap.containsKey(IMT.toString()) + ? readValues(IMT, request, Imt.class) + : supportedImts; + OptionalDouble returnPeriod = OptionalDouble.empty(); + + /* Possibly update for deagg. */ + if (paramMap.containsKey(RETURNPERIOD.toString())) { + returnPeriod = OptionalDouble.of(readDouble(RETURNPERIOD, request)); + } + + return new RequestData( + edition, + region, + lon, + lat, + imts, + vs30, + returnPeriod); + } + + /* + * Reduce slash-delimited request. This method is shared with deagg. Deagg + * must supply a single Imt. See RequestData notes below. + */ + static RequestData buildRequest(List params) { + + /* Read params as for hazard */ + double lon = Double.valueOf(params.get(2)); + double lat = Double.valueOf(params.get(3)); + Vs30 vs30 = Vs30.fromValue(Double.valueOf(params.get(5))); + Edition edition = Enum.valueOf(Edition.class, params.get(0)); + Region region = ServletUtil.checkRegion( + Enum.valueOf(Region.class, params.get(1)), + lon); + Set supportedImts = Metadata.commonImts(edition, region); + Set imts = (params.get(4).equalsIgnoreCase("any")) + ? supportedImts + : readValues(params.get(4), Imt.class); + OptionalDouble returnPeriod = OptionalDouble.empty(); + + /* Possibly update for deagg. */ + if (params.size() == 7) { + returnPeriod = OptionalDouble.of(Double.valueOf(params.get(6))); + } + + return new RequestData( + edition, + region, + lon, + lat, + imts, + vs30, + returnPeriod); + } + + private static class HazardTask extends TimedTask { + + final RequestData data; + + HazardTask(String url, ServletContext context, RequestData data) { + super(url, context); + this.data = data; + } + + @Override + Result calc() throws Exception { + Hazard hazard = calcHazard(data, context); + return new Result.Builder() + .requestData(data) + .url(url) + .timer(timer) + .hazard(hazard) + .build(); + } + } + + /* Also used by DeaggService */ + static Hazard calcHazard(RequestData data, ServletContext context) { + + Location loc = Location.create(data.latitude, data.longitude); + Site.Builder siteBuilder = Site.builder().location(loc).vs30(data.vs30.value()); + + @SuppressWarnings("unchecked") + LoadingCache modelCache = + (LoadingCache) context.getAttribute(MODEL_CACHE_CONTEXT_ID); + + // TODO cache calls should be using checked get(id) + + // May include trailing 'B' for 2014B + String baseYear = data.edition.name().substring(1); + + /* + * When combining (merging) Hazard, the config from the first supplied + * Hazard is used for the result. This means, for example, the exceedance + * model used for deaggregation may be different than that used to compute + * the original hazard curves. Because the CEUS exceedance model, + * NSHM_CEUS_MAX_INTENSITY, is really just a 3σ truncation model except + * close to New Madrid when fixed maximum values apply, it is okay to just + * use the WUS 3σ truncation exceedance model in the CEUS-WUS overlap zone. + * However, it is important to have the WUS result be first in the merge() + * call below. + */ + if (data.region == COUS) { + + Model wusId = Model.valueOf(WUS.name() + "_" + baseYear); + HazardModel wusModel = modelCache.getUnchecked(wusId); + Site site = siteBuilder + .basinDataProvider(wusModel.config().siteData.basinDataProvider) + .build(); + Hazard wusResult = process(wusModel, site, data.imts); + + String ceusYear = baseYear.equals("2014B") ? "2014" : baseYear; + Model ceusId = Model.valueOf(CEUS.name() + "_" + ceusYear); + HazardModel ceusModel = modelCache.getUnchecked(ceusId); + Hazard ceusResult = process(ceusModel, site, data.imts); + + return Hazard.merge(wusResult, ceusResult); + } + + String year = (baseYear.equals("2014B") && data.region == Region.CEUS) + ? "2014" : baseYear; + Model modelId = Model.valueOf(data.region.name() + "_" + year); + HazardModel model = modelCache.getUnchecked(modelId); + Site site = siteBuilder.basinDataProvider(model.config().siteData.basinDataProvider).build(); + return process(model, site, data.imts); + } + + private static Hazard process(HazardModel model, Site site, Set imts) { + Builder configBuilder = CalcConfig.Builder.copyOf(model.config()); + configBuilder.imts(imts); + CalcConfig config = configBuilder.build(); + return HazardCalcs.hazard(model, config, site, ServletUtil.CALC_EXECUTOR); + } + + /* + * We use a single request object type for both hazard and deagg. With the + * extension of deagg to support CMS, we need medians and sigmas for other + * spectral periods, but we don't (at least not yet) want to return serialized + * deaggs across all periods. Whereas both underlying programs support + * multiple Imts, the hazard service expects a set of Imts (that may + * correspond to 'any' implying all supportedImts), but the deagg service + * expects a single Imt. To address this, we've added the deaggImt field, + * which will be derived from the singleton imt URL argument as before, but + * hazard will always be computed across the set of Imts supplied so that the + * cms at the deaggImt can be computed. Under the hood the deagg application + * provides support for all specified Imts. Also note that the presence of a + * 'return period' is used to flag deagg service requests. + */ + static final class RequestData { + + final Edition edition; + final Region region; + final double latitude; + final double longitude; + final Set imts; + final Vs30 vs30; + final OptionalDouble returnPeriod; + + RequestData( + Edition edition, + Region region, + double longitude, + double latitude, + Set imts, + Vs30 vs30, + OptionalDouble returnPeriod) { + + this.edition = edition; + this.region = region; + this.latitude = latitude; + this.longitude = longitude; + this.imts = imts; + this.vs30 = vs30; + this.returnPeriod = returnPeriod; + } + } + + private static final class ResponseData { + + final Edition edition; + final Region region; + final double latitude; + final double longitude; + final Imt imt; + final Vs30 vs30; + final String xlabel = "Ground Motion (g)"; + final String ylabel = "Annual Frequency of Exceedence"; + final List xvalues; + + ResponseData(RequestData request, Imt imt, List xvalues) { + this.edition = request.edition; + this.region = request.region; + this.longitude = request.longitude; + this.latitude = request.latitude; + this.imt = imt; + this.vs30 = request.vs30; + this.xvalues = xvalues; + } + } + + private static final class Response { + + final ResponseData metadata; + final List data; + + Response(ResponseData metadata, List data) { + this.metadata = metadata; + this.data = data; + } + } + + private static final class Curve { + + final String component; + final List yvalues; + + Curve(String component, List yvalues) { + this.component = component; + this.yvalues = yvalues; + } + } + + private static final String TOTAL_KEY = "Total"; + + private static final class Result { + + final String status = Status.SUCCESS.toString(); + final String date = ZonedDateTime.now().format(ServletUtil.DATE_FMT); + final String url; + final Object server; + final List response; + + Result(String url, Object server, List response) { + this.url = url; + this.server = server; + this.response = response; + } + + static final class Builder { + + String url; + Timer timer; + RequestData request; + + Map> componentMaps; + Map totalMap; + Map> xValuesLinearMap; + + Builder hazard(Hazard hazardResult) { + checkState(totalMap == null, "Hazard has already been added to this builder"); + + componentMaps = new EnumMap<>(Imt.class); + totalMap = new EnumMap<>(Imt.class); + xValuesLinearMap = new EnumMap<>(Imt.class); + + Map> typeTotalMaps = curvesBySource(hazardResult); + + for (Imt imt : hazardResult.curves().keySet()) { + + // total curve + hazardResult.curves().get(imt).addToMap(imt, totalMap); + + // component curves + Map typeTotalMap = typeTotalMaps.get(imt); + Map componentMap = componentMaps.get(imt); + if (componentMap == null) { + componentMap = new EnumMap<>(SourceType.class); + componentMaps.put(imt, componentMap); + } + + for (SourceType type : typeTotalMap.keySet()) { + typeTotalMap.get(type).addToMap(type, componentMap); + } + + xValuesLinearMap.put( + imt, + hazardResult.config().hazard.modelCurve(imt).xValues()); + } + return this; + } + + Builder url(String url) { + this.url = url; + return this; + } + + Builder timer(Timer timer) { + this.timer = timer; + return this; + } + + Builder requestData(RequestData request) { + this.request = request; + return this; + } + + Result build() { + ImmutableList.Builder responseListBuilder = ImmutableList.builder(); + + for (Imt imt : totalMap.keySet()) { + + ResponseData responseData = new ResponseData( + request, + imt, + xValuesLinearMap.get(imt)); + + ImmutableList.Builder curveListBuilder = ImmutableList.builder(); + + // total curve + Curve totalCurve = new Curve( + TOTAL_KEY, + totalMap.get(imt).yValues()); + curveListBuilder.add(totalCurve); + + // component curves + Map typeMap = componentMaps.get(imt); + for (SourceType type : typeMap.keySet()) { + Curve curve = new Curve( + type.toString(), + typeMap.get(type).yValues()); + curveListBuilder.add(curve); + } + + Response response = new Response(responseData, curveListBuilder.build()); + responseListBuilder.add(response); + } + + List responseList = responseListBuilder.build(); + Object server = Metadata.serverData(ServletUtil.THREAD_COUNT, timer); + + return new Result(url, server, responseList); + } + } + } +} diff --git a/src/gov/usgs/earthquake/nshmp/www/HazardService2.java b/src/gov/usgs/earthquake/nshmp/www/HazardService2.java new file mode 100644 index 000000000..c82de2c23 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/HazardService2.java @@ -0,0 +1,383 @@ +package gov.usgs.earthquake.nshmp.www; + +import static com.google.common.base.Preconditions.checkState; +import static gov.usgs.earthquake.nshmp.calc.HazardExport.curvesBySource; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.GSON; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.MODEL_CACHE_CONTEXT_ID; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.emptyRequest; +import static gov.usgs.earthquake.nshmp.www.Util.readDouble; +import static gov.usgs.earthquake.nshmp.www.Util.readValue; +import static gov.usgs.earthquake.nshmp.www.Util.Key.LATITUDE; +import static gov.usgs.earthquake.nshmp.www.Util.Key.LONGITUDE; +import static gov.usgs.earthquake.nshmp.www.Util.Key.MODEL; +import static gov.usgs.earthquake.nshmp.www.Util.Key.VS30; + +import java.io.IOException; +import java.time.ZonedDateTime; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableList; + +import gov.usgs.earthquake.nshmp.calc.CalcConfig; +import gov.usgs.earthquake.nshmp.calc.CalcConfig.Builder; +import gov.usgs.earthquake.nshmp.calc.Hazard; +import gov.usgs.earthquake.nshmp.calc.HazardCalcs; +import gov.usgs.earthquake.nshmp.calc.Site; +import gov.usgs.earthquake.nshmp.calc.Vs30; +import gov.usgs.earthquake.nshmp.data.XySequence; +import gov.usgs.earthquake.nshmp.eq.model.HazardModel; +import gov.usgs.earthquake.nshmp.eq.model.SourceType; +import gov.usgs.earthquake.nshmp.geo.Location; +import gov.usgs.earthquake.nshmp.gmm.Imt; +import gov.usgs.earthquake.nshmp.internal.Parsing; +import gov.usgs.earthquake.nshmp.internal.Parsing.Delimiter; +import gov.usgs.earthquake.nshmp.www.ServletUtil.TimedTask; +import gov.usgs.earthquake.nshmp.www.ServletUtil.Timer; +import gov.usgs.earthquake.nshmp.www.SourceServices.SourceModel; +import gov.usgs.earthquake.nshmp.www.meta.Metadata; +import gov.usgs.earthquake.nshmp.www.meta.Status; + +/** + * Probabilisitic seismic hazard calculation service. + * + * @author Peter Powers + */ +@SuppressWarnings("unused") +@WebServlet( + name = "Hazard Service 2", + description = "USGS NSHMP Hazard Curve Calculator", + urlPatterns = { + "/haz", + "/haz/*" }) +public final class HazardService2 extends NshmpServlet { + + /* + * Developer notes: + * + * Updated hazard service that identifies models directly, instead of + * editions, to simplify model comparison. Models are defined by a region and + * year. This service computes hazard for all supported IMTs and a single + * vs30. + * + * As with the existing hazard service, calculations are designed to leverage + * all available processors by default, distributing work using the + * ServletUtil.CALC_EXECUTOR. This can create problems in a servlet + * environment, however, because Tomcat does not support a single threaded + * request queue where requests are processed as they are received with the + * next task starting only once the prior has finished. One can really only + * limit the maximum number of simultaneous requests. When multiple requests + * are received in a short span, Tomcat will attempt to run hazard or deagg + * calculations simultaneously. The net effect is that there can be out of + * memory problems as too many results are retained, and multiple requests do + * not return until all are finished. + * + * To address this, requests are submitted as tasks to the single-threaded + * ServletUtil.TASK_EXECUTOR and are processed one-at-a-time in the order + * received. + * + * TODO Add support for multi model requests in order to combine models per + * the original hazard service. + */ + + private LoadingCache modelCache; + + private static final String USAGE = SourceServices.GSON.toJson( + new SourceServices.ResponseData()); + + @Override + @SuppressWarnings("unchecked") + public void init() { + ServletContext context = getServletConfig().getServletContext(); + Object modelCache = context.getAttribute(MODEL_CACHE_CONTEXT_ID); + this.modelCache = (LoadingCache) modelCache; + } + + @Override + protected void doGet( + HttpServletRequest request, + HttpServletResponse response) + throws ServletException, IOException { + + UrlHelper urlHelper = urlHelper(request, response); + + if (emptyRequest(request)) { + urlHelper.writeResponse(USAGE); + return; + } + + try { + RequestData requestData = buildRequestData(request); + + /* Submit as task to job executor */ + Hazard2Task task = new Hazard2Task(urlHelper.url, getServletContext(), requestData); + Result result = ServletUtil.TASK_EXECUTOR.submit(task).get(); + GSON.toJson(result, response.getWriter()); + + } catch (Exception e) { + String message = Metadata.errorMessage(urlHelper.url, e, false); + response.getWriter().print(message); + getServletContext().log(urlHelper.url, e); + } + } + + /* Reduce query string key-value pairs. */ + static RequestData buildRequestData(HttpServletRequest request) { + + try { + + Model model; + double lon; + double lat; + Vs30 vs30; + + if (request.getQueryString() != null) { + /* process query '?' request */ + model = readValue(MODEL, request, Model.class); + lon = readDouble(LONGITUDE, request); + lat = readDouble(LATITUDE, request); + vs30 = Vs30.fromValue(readDouble(VS30, request)); + + } else { + /* process slash-delimited request */ + List params = Parsing.splitToList( + request.getPathInfo(), + Delimiter.SLASH); + model = Model.valueOf(params.get(0)); + lon = Double.valueOf(params.get(1)); + lat = Double.valueOf(params.get(2)); + vs30 = Vs30.fromValue(Double.valueOf(params.get(3))); + } + + return new RequestData( + model, + lon, + lat, + vs30); + + } catch (Exception e) { + throw new IllegalArgumentException("Error parsing request URL", e); + } + } + + private class Hazard2Task extends TimedTask { + + final RequestData data; + + Hazard2Task(String url, ServletContext context, RequestData data) { + super(url, context); + this.data = data; + } + + @Override + Result calc() throws Exception { + Hazard hazard = calcHazard(data, context); + return new Result.Builder() + .requestData(data) + .url(url) + .timer(timer) + .hazard(hazard) + .build(); + } + } + + Hazard calcHazard(RequestData data, ServletContext context) { + Location loc = Location.create(data.latitude, data.longitude); + HazardModel model = modelCache.getUnchecked(data.model); + Builder configBuilder = CalcConfig.Builder.copyOf(model.config()); + configBuilder.imts(data.model.imts); + CalcConfig config = configBuilder.build(); + + Site site = Site.builder() + .basinDataProvider(config.siteData.basinDataProvider) + .location(loc) + .vs30(data.vs30.value()) + .build(); + + return HazardCalcs.hazard(model, config, site, ServletUtil.CALC_EXECUTOR); + } + + static final class RequestData { + + final Model model; + final double latitude; + final double longitude; + final Vs30 vs30; + + RequestData( + Model model, + double longitude, + double latitude, + Vs30 vs30) { + + this.model = model; + this.latitude = latitude; + this.longitude = longitude; + this.vs30 = vs30; + } + } + + private static final class ResponseData { + + final SourceModel model; + final double latitude; + final double longitude; + final Imt imt; + final Vs30 vs30; + final String xlabel = "Ground Motion (g)"; + final String ylabel = "Annual Frequency of Exceedence"; + final List xvalues; + + ResponseData(RequestData request, Imt imt, List xvalues) { + this.model = new SourceModel(request.model); + this.latitude = request.latitude; + this.longitude = request.longitude; + this.imt = imt; + this.vs30 = request.vs30; + this.xvalues = xvalues; + } + } + + private static final class Response { + + final ResponseData metadata; + final List data; + + Response(ResponseData metadata, List data) { + this.metadata = metadata; + this.data = data; + } + } + + private static final class Curve { + + final String component; + final List yvalues; + + Curve(String component, List yvalues) { + this.component = component; + this.yvalues = yvalues; + } + } + + private static final String TOTAL_KEY = "Total"; + + private static final class Result { + + final String status = Status.SUCCESS.toString(); + final String date = ZonedDateTime.now().format(ServletUtil.DATE_FMT); + final String url; + final Object server; + final List response; + + Result(String url, Object server, List response) { + this.url = url; + this.server = server; + this.response = response; + } + + static final class Builder { + + String url; + Timer timer; + RequestData request; + + Map> componentMaps; + Map totalMap; + Map> xValuesLinearMap; + + Builder hazard(Hazard hazardResult) { + checkState(totalMap == null, "Hazard has already been added to this builder"); + + componentMaps = new EnumMap<>(Imt.class); + totalMap = new EnumMap<>(Imt.class); + xValuesLinearMap = new EnumMap<>(Imt.class); + + Map> typeTotalMaps = curvesBySource(hazardResult); + + for (Imt imt : hazardResult.curves().keySet()) { + + // total curve + hazardResult.curves().get(imt).addToMap(imt, totalMap); + + // component curves + Map typeTotalMap = typeTotalMaps.get(imt); + Map componentMap = componentMaps.get(imt); + if (componentMap == null) { + componentMap = new EnumMap<>(SourceType.class); + componentMaps.put(imt, componentMap); + } + + for (SourceType type : typeTotalMap.keySet()) { + typeTotalMap.get(type).addToMap(type, componentMap); + } + + xValuesLinearMap.put( + imt, + hazardResult.config().hazard.modelCurve(imt).xValues()); + } + return this; + } + + Builder url(String url) { + this.url = url; + return this; + } + + Builder timer(Timer timer) { + this.timer = timer; + return this; + } + + Builder requestData(RequestData request) { + this.request = request; + return this; + } + + Result build() { + ImmutableList.Builder responseListBuilder = ImmutableList.builder(); + + for (Imt imt : totalMap.keySet()) { + + ResponseData responseData = new ResponseData( + request, + imt, + xValuesLinearMap.get(imt)); + + ImmutableList.Builder curveListBuilder = ImmutableList.builder(); + + // total curve + Curve totalCurve = new Curve( + TOTAL_KEY, + totalMap.get(imt).yValues()); + curveListBuilder.add(totalCurve); + + // component curves + Map typeMap = componentMaps.get(imt); + for (SourceType type : typeMap.keySet()) { + Curve curve = new Curve( + type.toString(), + typeMap.get(type).yValues()); + curveListBuilder.add(curve); + } + + Response response = new Response(responseData, curveListBuilder.build()); + responseListBuilder.add(response); + } + + List responseList = responseListBuilder.build(); + Object server = Metadata.serverData(ServletUtil.THREAD_COUNT, timer); + + return new Result(url, server, responseList); + } + } + } +} diff --git a/src/gov/usgs/earthquake/nshmp/www/Model.java b/src/gov/usgs/earthquake/nshmp/www/Model.java new file mode 100644 index 000000000..25094cbfa --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/Model.java @@ -0,0 +1,81 @@ +package gov.usgs.earthquake.nshmp.www; + +import static gov.usgs.earthquake.nshmp.calc.Vs30.*; +import static gov.usgs.earthquake.nshmp.gmm.Imt.*; +import static gov.usgs.earthquake.nshmp.www.meta.Region.*; + +import java.nio.file.Paths; +import java.util.EnumSet; +import java.util.Set; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Sets; + +import gov.usgs.earthquake.nshmp.calc.Vs30; +import gov.usgs.earthquake.nshmp.gmm.Imt; +import gov.usgs.earthquake.nshmp.internal.Parsing; +import gov.usgs.earthquake.nshmp.internal.Parsing.Delimiter; +import gov.usgs.earthquake.nshmp.www.meta.Region; + +enum Model { + + AK_2007( + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA1P0, SA2P0), + EnumSet.of(VS_760)), + + CEUS_2008( + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA1P0, SA2P0), + EnumSet.of(VS_760, VS_2000)), + + WUS_2008( + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA0P75, SA1P0, SA2P0, SA3P0), + EnumSet.of(VS_1150, VS_760, VS_537, VS_360, VS_259, VS_180)), + + CEUS_2014( + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA1P0, SA2P0), + EnumSet.of(VS_760, VS_2000)), + + WUS_2014( + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA0P75, SA1P0, SA2P0, SA3P0), + EnumSet.of(VS_1150, VS_760, VS_537, VS_360, VS_259, VS_180)), + + WUS_2014B( + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA0P75, SA1P0, SA2P0, SA3P0, SA4P0, SA5P0), + EnumSet.of(VS_1150, VS_760, VS_537, VS_360, VS_259, VS_180)), + + CEUS_2018( + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA0P75, SA1P0, SA2P0, SA3P0, SA4P0, SA5P0), + EnumSet.of(VS_1150, VS_760, VS_537, VS_360, VS_259, VS_180)), + + WUS_2018( + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA0P75, SA1P0, SA2P0, SA3P0, SA4P0, SA5P0), + EnumSet.of(VS_1150, VS_760, VS_537, VS_360, VS_259, VS_180)); + + private static final String MODEL_DIR = "models"; + + final Set imts; + final Set vs30s; + + final String path; + final String name; + final Region region; + final String year; + + private Model(Set imts, Set vs30s) { + this.imts = Sets.immutableEnumSet(imts); + this.vs30s = Sets.immutableEnumSet(vs30s); + region = deriveRegion(name()); + year = name().substring(name().lastIndexOf('_') + 1); + path = Paths.get("/", MODEL_DIR) + .resolve(region.name().toLowerCase()) + .resolve(year.toLowerCase()) + .toString(); + name = Parsing.join( + ImmutableList.of(year, region.label, "Hazard Model"), + Delimiter.SPACE); + } + + private static Region deriveRegion(String s) { + return s.startsWith("AK") ? AK : s.startsWith("WUS") ? WUS : CEUS; + } +} diff --git a/src/gov/usgs/earthquake/nshmp/www/NshmpServlet.java b/src/gov/usgs/earthquake/nshmp/www/NshmpServlet.java new file mode 100644 index 000000000..b33247816 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/NshmpServlet.java @@ -0,0 +1,106 @@ +package gov.usgs.earthquake.nshmp.www; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Custom NSHMP servlet implementation and URL helper class. + * + *

All nshmp-haz-ws services should extend this class. This class sets custom + * response headers and provides a helper class to ensure serialized response + * URLs propagate the correct host and protocol from requests on USGS servers + * and caches that may have been forwarded. + * + *

Class provides one convenience method, + * {@code urlHelper.writeResponse(String)}, to write a servlet response wherein + * any URL strings may be formatted with the correct protocol and host. Such URL + * strings should start with: + * + * "%s://%s/service-name/..." + * + * @author Peter Powers + */ +public abstract class NshmpServlet extends HttpServlet { + + @Override + protected void service( + HttpServletRequest request, + HttpServletResponse response) + throws ServletException, IOException { + + /* + * Set CORS headers and content type. + * + * Because nshmp-haz-ws services may be called by both the USGS website, + * other websites, and directly by 3rd party applications, reponses + * generated by direct requests will not have the necessary header + * information that would be required by security protocols for web + * requests. This means that any initial direct request will pollute + * intermediate caches with a response that a browser will deem invalid. + */ + response.setContentType("application/json; charset=UTF-8"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "*"); + response.setHeader("Access-Control-Allow-Headers", "accept,origin,authorization,content-type"); + + super.service(request, response); + } + + public static UrlHelper urlHelper(HttpServletRequest request, HttpServletResponse response) + throws IOException { + return new UrlHelper(request, response); + } + + public static class UrlHelper { + + private final HttpServletResponse response; + private final String host; + private final String protocol; + public final String url; + + UrlHelper(HttpServletRequest request, HttpServletResponse response) { + + /* + * Check custom header for a forwarded protocol so generated links can use + * the same protocol and not cause mixed content errors. + */ + String host = request.getServerName(); + String protocol = request.getHeader("X-FORWARDED-PROTO"); + if (protocol == null) { + /* Not a forwarded request. Honor reported protocol and port. */ + protocol = request.getScheme(); + host += ":" + request.getServerPort(); + } + + /* + * For convenience, store a url field with the (possibly updated) request + * protocol and + */ + StringBuffer urlBuf = request.getRequestURL(); + String query = request.getQueryString(); + if (query != null) urlBuf.append('?').append(query); + String url = urlBuf.toString().replace("http://", protocol + "://"); + + this.response = response; + this.host = host; + this.protocol = protocol; + this.url = url; + } + + /** + * Convenience method to update a string response with the correct protocol + * and host in URLs. URL strings should start with: + * + * "%s://%s/service-name/..." + */ + public void writeResponse(String usage) throws IOException { + // TODO had to add duplicate fields to handle haz and g syntax strings + response.getWriter().printf(usage, protocol, host, protocol, host); + } + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/RateService.java b/src/gov/usgs/earthquake/nshmp/www/RateService.java new file mode 100644 index 000000000..92dce4f8c --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/RateService.java @@ -0,0 +1,445 @@ +package gov.usgs.earthquake.nshmp.www; + +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.base.Strings.isNullOrEmpty; +import static gov.usgs.earthquake.nshmp.calc.ValueFormat.ANNUAL_RATE; +import static gov.usgs.earthquake.nshmp.calc.ValueFormat.POISSON_PROBABILITY; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.GSON; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.MODEL_CACHE_CONTEXT_ID; +import static gov.usgs.earthquake.nshmp.www.ServletUtil.emptyRequest; +import static gov.usgs.earthquake.nshmp.www.Util.readDouble; +import static gov.usgs.earthquake.nshmp.www.Util.readValue; +import static gov.usgs.earthquake.nshmp.www.Util.Key.DISTANCE; +import static gov.usgs.earthquake.nshmp.www.Util.Key.EDITION; +import static gov.usgs.earthquake.nshmp.www.Util.Key.LATITUDE; +import static gov.usgs.earthquake.nshmp.www.Util.Key.LONGITUDE; +import static gov.usgs.earthquake.nshmp.www.Util.Key.REGION; +import static gov.usgs.earthquake.nshmp.www.Util.Key.TIMESPAN; +import static gov.usgs.earthquake.nshmp.www.meta.Region.CEUS; +import static gov.usgs.earthquake.nshmp.www.meta.Region.WUS; + +import java.util.Optional; +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.ListenableFuture; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; + +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import gov.usgs.earthquake.nshmp.calc.CalcConfig; +import gov.usgs.earthquake.nshmp.calc.CalcConfig.Builder; +import gov.usgs.earthquake.nshmp.calc.EqRate; +import gov.usgs.earthquake.nshmp.calc.Site; +import gov.usgs.earthquake.nshmp.calc.ValueFormat; +import gov.usgs.earthquake.nshmp.data.XySequence; +import gov.usgs.earthquake.nshmp.eq.model.HazardModel; +import gov.usgs.earthquake.nshmp.eq.model.SourceType; +import gov.usgs.earthquake.nshmp.geo.Location; +import gov.usgs.earthquake.nshmp.internal.Parsing; +import gov.usgs.earthquake.nshmp.internal.Parsing.Delimiter; +import gov.usgs.earthquake.nshmp.www.NshmpServlet.UrlHelper; +import gov.usgs.earthquake.nshmp.www.ServletUtil.Timer; +import gov.usgs.earthquake.nshmp.www.meta.Edition; +import gov.usgs.earthquake.nshmp.www.meta.Metadata; +import gov.usgs.earthquake.nshmp.www.meta.Region; +import gov.usgs.earthquake.nshmp.www.meta.Status; + +/** + * Earthquake probability and rate calculation service. + * + * @author Peter Powers + */ +@SuppressWarnings("unused") +@WebServlet( + name = "Earthquake Probability & Rate Service", + description = "USGS NSHMP Earthquake Probability & Rate Calculator", + urlPatterns = { + "/rate", + "/rate/*", + "/probability", + "/probability/*" }) +public final class RateService extends NshmpServlet { + + /* + * Developer notes: + * + * The RateService is currently single-threaded and does not submit jobs to a + * request queue; see HazardService. However, jobs are placed on a thread in + * the CALC_EXECUTOR thread pool to handle parallel calculation of CEUS and + * WUS models. + */ + + @Override + protected void doGet( + HttpServletRequest request, + HttpServletResponse response) + throws ServletException, IOException { + + Timer timer = ServletUtil.timer(); + + UrlHelper urlHelper = urlHelper(request, response); + String query = request.getQueryString(); + String pathInfo = request.getPathInfo(); + String service = request.getServletPath(); + + ValueFormat format = service.equals("/rate") ? ANNUAL_RATE : POISSON_PROBABILITY; + String usage = (format == ANNUAL_RATE) ? Metadata.RATE_USAGE : Metadata.PROBABILITY_USAGE; + int paramCount = (format == ANNUAL_RATE) ? 5 : 6; + + if (emptyRequest(request)) { + urlHelper.writeResponse(usage); + return; + } + + if (ServletUtil.uhtBusy) { + ServletUtil.missCount++; + String message = Metadata.busyMessage( + urlHelper.url, + ServletUtil.hitCount, + ServletUtil.missCount); + //response.setStatus(503); + response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); + response.getWriter().print(message); + return; + } + + RequestData requestData; + ServletUtil.uhtBusy = true; + try { + if (query != null) { + /* process query '?' request */ + requestData = buildRequest(request, format); + } else { + /* process slash-delimited request */ + List params = Parsing.splitToList(pathInfo, Delimiter.SLASH); + if (params.size() < paramCount) { + urlHelper.writeResponse(usage); + return; + } + requestData = buildRequest(params, format); + } + + EqRate rates = calc(requestData, getServletContext()); + Result result = new Result.Builder() + .requestData(requestData) + .url(urlHelper.url) + .timer(timer) + .rates(rates) + .build(); + String resultStr = GSON.toJson(result); + response.getWriter().print(resultStr); + + } catch (Exception e) { + String message = Metadata.errorMessage(urlHelper.url, e, false); + response.getWriter().print(message); + getServletContext().log(urlHelper.url, e); + } + ServletUtil.hitCount++; + ServletUtil.uhtBusy = false; + } + + /* Reduce query string key-value pairs */ + private RequestData buildRequest(HttpServletRequest request, ValueFormat format) { + + Optional timespan = (format == POISSON_PROBABILITY) + ? Optional.of(readDouble(TIMESPAN, request)) : Optional. empty(); + + return new RequestData( + readValue(EDITION, request, Edition.class), + readValue(REGION, request, Region.class), + readDouble(LONGITUDE, request), + readDouble(LATITUDE, request), + readDouble(DISTANCE, request), + timespan); + } + + /* Reduce slash-delimited request */ + private RequestData buildRequest(List params, ValueFormat format) { + + Optional timespan = (format == POISSON_PROBABILITY) + ? Optional.of(Double.valueOf(params.get(5))) : Optional. empty(); + + return new RequestData( + Enum.valueOf(Edition.class, params.get(0)), + Enum.valueOf(Region.class, params.get(1)), + Double.valueOf(params.get(2)), + Double.valueOf(params.get(3)), + Double.valueOf(params.get(4)), + timespan); + } + + /* + * TODO delete if not needed + * + * Currently unused, however, will be used if it makes sense to submit jobs to + * TASK_EXECUTOR. + */ + private static class RateTask implements Callable { + + final String url; + final RequestData data; + final ServletContext context; + final Timer timer; + + RateTask(String url, RequestData data, ServletContext context) { + this.url = url; + this.data = data; + this.context = context; + this.timer = ServletUtil.timer(); + } + + @Override + public Result call() throws Exception { + EqRate rates = calc(data, context); + return new Result.Builder() + .requestData(data) + .url(url) + .timer(timer) + .rates(rates) + .build(); + } + } + + private static EqRate calc(RequestData data, ServletContext context) + throws InterruptedException, ExecutionException { + + Location location = Location.create(data.latitude, data.longitude); + Site site = Site.builder().location(location).build(); + + double distance = data.distance; + + @SuppressWarnings("unchecked") + LoadingCache modelCache = + (LoadingCache) context.getAttribute(MODEL_CACHE_CONTEXT_ID); + + EqRate rates; + + /* + * Because we need to combine model results, intially calculate incremental + * annual rates and only convert to cumulative probabilities at the end if + * probability service has been called. + */ + Optional emptyTimespan = Optional. empty(); + + // May include trailing 'B' for 2014B + String baseYear = data.edition.name().substring(1); + + if (data.region == Region.COUS) { + + Model wusId = Model.valueOf(WUS.name() + "_" + baseYear); + HazardModel wusModel = modelCache.get(wusId); + ListenableFuture wusRates = process(wusModel, site, distance, emptyTimespan); + + String ceusYear = baseYear.equals("2014B") ? "2014" : baseYear; + Model ceusId = Model.valueOf(CEUS.name() + "_" + ceusYear); + HazardModel ceusModel = modelCache.get(ceusId); + ListenableFuture ceusRates = process(ceusModel, site, distance, emptyTimespan); + + rates = EqRate.combine(wusRates.get(), ceusRates.get()); + + } else { + + String year = (baseYear.equals("2014B") && data.region == Region.CEUS) + ? "2014" : baseYear; + Model modelId = Model.valueOf(data.region.name() + "_" + year); + + HazardModel model = modelCache.get(modelId); + rates = process(model, site, distance, emptyTimespan).get(); + } + + if (data.timespan.isPresent()) { + rates = EqRate.toCumulative(rates); + rates = EqRate.toPoissonProbability(rates, data.timespan.get()); + } + return rates; + } + + private static ListenableFuture process( + HazardModel model, + Site site, + double distance, + Optional timespan) { + + Builder configBuilder = CalcConfig.Builder + .copyOf(model.config()) + .distance(distance); + if (timespan.isPresent()) { + /* Also sets value format to Poisson probability. */ + configBuilder.timespan(timespan.get()); + } + CalcConfig config = configBuilder.build(); + Callable task = EqRate.callable(model, config, site); + return ServletUtil.CALC_EXECUTOR.submit(task); + } + + static final class RequestData { + + final Edition edition; + final Region region; + final double latitude; + final double longitude; + final double distance; + final Optional timespan; + + RequestData( + Edition edition, + Region region, + double longitude, + double latitude, + double distance, + Optional timespan) { + + this.edition = edition; + this.region = region; + this.latitude = latitude; + this.longitude = longitude; + this.distance = distance; + this.timespan = timespan; + } + } + + private static final class ResponseData { + + final Edition edition; + final Region region; + final double latitude; + final double longitude; + final double distance; + final Double timespan; + + final String xlabel = "Magnitude (Mw)"; + final String ylabel; + + ResponseData(RequestData request) { + boolean isProbability = request.timespan.isPresent(); + this.edition = request.edition; + this.region = request.region; + this.longitude = request.longitude; + this.latitude = request.latitude; + this.distance = request.distance; + this.ylabel = isProbability ? "Probability" : "Annual Rate (yr⁻¹)"; + this.timespan = request.timespan.orElse(null); + } + } + + private static final class Response { + + final ResponseData metadata; + final List data; + + Response(ResponseData metadata, List data) { + this.metadata = metadata; + this.data = data; + } + } + + /* + * TODO would rather use this a general container for mfds and hazard curves. + * See HazardService.Curve + */ + private static class Sequence { + + final String component; + final List xvalues; + final List yvalues; + + Sequence(String component, List xvalues, List yvalues) { + this.component = component; + this.xvalues = xvalues; + this.yvalues = yvalues; + } + } + + private static final String TOTAL_KEY = "Total"; + + private static final class Result { + + final String status = Status.SUCCESS.toString(); + final String date = ZonedDateTime.now().format(ServletUtil.DATE_FMT); + final String url; + final Object server; + final Response response; + + Result(String url, Object server, Response response) { + this.url = url; + this.server = server; + this.response = response; + } + + static final class Builder { + + String url; + Timer timer; + RequestData request; + EqRate rates; + + Builder rates(EqRate rates) { + checkState(this.rates == null, "Rate data has already been added to this builder"); + this.rates = rates; + return this; + } + + Builder url(String url) { + this.url = url; + return this; + } + + Builder timer(Timer timer) { + this.timer = timer; + return this; + } + + Builder requestData(RequestData request) { + this.request = request; + return this; + } + + Result build() { + + ImmutableList.Builder sequenceListBuilder = ImmutableList.builder(); + + /* Total mfd. */ + XySequence total = (!rates.totalMfd.isClear()) ? rates.totalMfd.trim() : rates.totalMfd; + Sequence totalOut = new Sequence( + TOTAL_KEY, + total.xValues(), + total.yValues()); + sequenceListBuilder.add(totalOut); + + /* Source type mfds. */ + for (Entry entry : rates.typeMfds.entrySet()) { + XySequence type = entry.getValue(); + if (type.isClear()) { + continue; + } + type = type.trim(); + Sequence typeOut = new Sequence( + entry.getKey().toString(), + type.xValues(), + type.yValues()); + sequenceListBuilder.add(typeOut); + } + + ResponseData responseData = new ResponseData(request); + Object server = Metadata.serverData(ServletUtil.THREAD_COUNT, timer); + Response response = new Response(responseData, sequenceListBuilder.build()); + + return new Result(url, server, response); + } + } + } +} diff --git a/src/gov/usgs/earthquake/nshmp/www/ServletUtil.java b/src/gov/usgs/earthquake/nshmp/www/ServletUtil.java new file mode 100644 index 000000000..004e735e6 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/ServletUtil.java @@ -0,0 +1,246 @@ +package gov.usgs.earthquake.nshmp.www; + +import static com.google.common.base.Strings.isNullOrEmpty; +import static gov.usgs.earthquake.nshmp.www.meta.Region.CEUS; +import static gov.usgs.earthquake.nshmp.www.meta.Region.COUS; +import static gov.usgs.earthquake.nshmp.www.meta.Region.WUS; +import static java.lang.Runtime.getRuntime; + +import java.net.URI; +import java.net.URL; +import java.nio.file.FileSystem; +import java.nio.file.FileSystemNotFoundException; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import javax.servlet.ServletContext; +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; +import javax.servlet.annotation.WebListener; +import javax.servlet.http.HttpServletRequest; + +import com.google.common.base.Stopwatch; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import gov.usgs.earthquake.nshmp.calc.Site; +import gov.usgs.earthquake.nshmp.calc.ValueFormat; +import gov.usgs.earthquake.nshmp.calc.Vs30; +import gov.usgs.earthquake.nshmp.eq.model.HazardModel; +import gov.usgs.earthquake.nshmp.gmm.Imt; +import gov.usgs.earthquake.nshmp.www.meta.Edition; +import gov.usgs.earthquake.nshmp.www.meta.ParamType; +import gov.usgs.earthquake.nshmp.www.meta.Region; +import gov.usgs.earthquake.nshmp.www.meta.Util; + +/** + * Servlet utility objects and methods. + * + * @author Peter Powers + */ +@SuppressWarnings("javadoc") +@WebListener +public class ServletUtil implements ServletContextListener { + + /* + * Some shared resources may be accessed statically, others, such as models, + * depend on a context-param and may be accessed as context attributes. + */ + + public static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern( + "yyyy-MM-dd'T'HH:mm:ssXXX"); + + static final ListeningExecutorService CALC_EXECUTOR; + static final ExecutorService TASK_EXECUTOR; + + static final int THREAD_COUNT; + + public static final Gson GSON; + + static final String MODEL_CACHE_CONTEXT_ID = "model.cache"; + + /* Stateful flag to reject requests while a result is pending. */ + static boolean uhtBusy = false; + static long hitCount = 0; + static long missCount = 0; + + static { + /* TODO modified for deagg-epsilon branch; should be context var */ + THREAD_COUNT = getRuntime().availableProcessors(); + CALC_EXECUTOR = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(THREAD_COUNT)); + TASK_EXECUTOR = Executors.newSingleThreadExecutor(); + GSON = new GsonBuilder() + .registerTypeAdapter(Edition.class, new Util.EnumSerializer()) + .registerTypeAdapter(Region.class, new Util.EnumSerializer()) + .registerTypeAdapter(Imt.class, new Util.EnumSerializer()) + .registerTypeAdapter(Vs30.class, new Util.EnumSerializer()) + .registerTypeAdapter(ValueFormat.class, new Util.EnumSerializer()) + .registerTypeAdapter(Double.class, new Util.DoubleSerializer()) + .registerTypeAdapter(ParamType.class, new Util.ParamTypeSerializer()) + .registerTypeAdapter(Site.class, new Util.SiteSerializer()) + .disableHtmlEscaping() + .serializeNulls() + .setPrettyPrinting() + .create(); + } + + @Override + public void contextDestroyed(ServletContextEvent e) { + CALC_EXECUTOR.shutdown(); + TASK_EXECUTOR.shutdown(); + } + + @Override + public void contextInitialized(ServletContextEvent e) { + + final ServletContext context = e.getServletContext(); + + final LoadingCache modelCache = CacheBuilder.newBuilder().build( + new CacheLoader() { + @Override + public HazardModel load(Model model) { + return loadModel(context, model); + } + }); + context.setAttribute(MODEL_CACHE_CONTEXT_ID, modelCache); + + // possibly fill (preload) cache + boolean preload = Boolean.valueOf(context.getInitParameter("preloadModels")); + + if (preload) { + for (final Model model : Model.values()) { + CALC_EXECUTOR.submit(new Callable() { + @Override + public HazardModel call() throws Exception { + return modelCache.getUnchecked(model); + } + }); + } + } + } + + private static HazardModel loadModel(ServletContext context, Model model) { + Path path; + URL url; + URI uri; + String uriString; + String[] uriParts; + FileSystem fs; + + try { + url = context.getResource(model.path); + uri = new URI(url.toString().replace(" ", "%20")); + uriString = uri.toString(); + + /* + * When the web sevice is deployed inside a WAR file (and not unpacked by + * the servlet container) model resources will not exist on disk as + * otherwise expected. In this case, load the resources directly out of + * the WAR file as well. This is slower, but with the preload option + * enabled it may be less of an issue if the models are already in memory. + */ + + if (uriString.indexOf("!") != -1) { + uriParts = uri.toString().split("!"); + + try { + fs = FileSystems.getFileSystem( + URI.create(uriParts[0])); + } catch (FileSystemNotFoundException fnx) { + fs = FileSystems.newFileSystem( + URI.create(uriParts[0]), + new HashMap()); + } + + path = fs.getPath(uriParts[1].replaceAll("%20", " ")); + } else { + path = Paths.get(uri); + } + + return HazardModel.load(path); + + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + static boolean emptyRequest(HttpServletRequest request) { + return isNullOrEmpty(request.getQueryString()) && + (request.getPathInfo() == null || request.getPathInfo().equals("/")); + } + + static Timer timer() { + return new Timer(); + } + + /* + * Simple timer object. The servlet timer just runs. The calculation timer can + * be started later. + */ + public static final class Timer { + + Stopwatch servlet = Stopwatch.createStarted(); + Stopwatch calc = Stopwatch.createUnstarted(); + + Timer start() { + calc.start(); + return this; + } + + public String servletTime() { + return servlet.toString(); + } + + public String calcTime() { + return calc.toString(); + } + } + + abstract static class TimedTask implements Callable { + + final String url; + final ServletContext context; + final Timer timer; + + TimedTask(String url, ServletContext context) { + this.url = url; + this.context = context; + this.timer = ServletUtil.timer(); + } + + abstract T calc() throws Exception; + + @Override + public T call() throws Exception { + timer.start(); + return calc(); + } + } + + /* + * For sites located west of -115 (in the WUS but not in the CEUS-WUS overlap + * zone) and site classes of vs30=760, client requests come in with + * region=COUS, thereby limiting the conversion of imt=any to the set of + * periods supported by both models. In order for the service to return what + * the client suggests should be returned, we need to do an addiitional + * longitude check. TODO clean; fix client eq-hazard-tool + */ + static Region checkRegion(Region region, double lon) { + if (region == COUS) { + return (lon <= WUS.uimaxlongitude) ? WUS : (lon >= CEUS.uiminlongitude) ? CEUS : COUS; + } + return region; + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/SourceServices.java b/src/gov/usgs/earthquake/nshmp/www/SourceServices.java new file mode 100644 index 000000000..bd0fbee7c --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/SourceServices.java @@ -0,0 +1,256 @@ +package gov.usgs.earthquake.nshmp.www; + +import static gov.usgs.earthquake.nshmp.www.meta.Metadata.serverData; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import gov.usgs.earthquake.nshmp.calc.Vs30; +import gov.usgs.earthquake.nshmp.gmm.Imt; +import gov.usgs.earthquake.nshmp.www.meta.DoubleParameter; +import gov.usgs.earthquake.nshmp.www.meta.EnumParameter; +import gov.usgs.earthquake.nshmp.www.meta.ParamType; +import gov.usgs.earthquake.nshmp.www.meta.Region; +import gov.usgs.earthquake.nshmp.www.meta.Status; +import gov.usgs.earthquake.nshmp.www.meta.Util; + +/** + * Entry point for services related to source models. Current services: + *

  • nshmp-haz-ws/source/
+ * + * @author Brandon Clayton + * @author Peter Powers + */ +@WebServlet( + name = "Source Services", + description = "Utilities for querying earthquake source models", + urlPatterns = { + "/source", + "/source/*" }) +@SuppressWarnings("unused") +public class SourceServices extends NshmpServlet { + private static final long serialVersionUID = 1L; + static final Gson GSON; + + static { + GSON = new GsonBuilder() + .registerTypeAdapter(Imt.class, new Util.EnumSerializer()) + .registerTypeAdapter(ParamType.class, new Util.ParamTypeSerializer()) + .registerTypeAdapter(Vs30.class, new Util.EnumSerializer()) + .registerTypeAdapter(Region.class, new RegionSerializer()) + .disableHtmlEscaping() + .serializeNulls() + .setPrettyPrinting() + .create(); + } + + @Override + protected void doGet( + HttpServletRequest request, + HttpServletResponse response) + throws ServletException, IOException { + + ResponseData svcResponse = null; + try { + svcResponse = new ResponseData(); + String jsonString = GSON.toJson(svcResponse); + response.getWriter().print(jsonString); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /* + * TODO service metadata should be in same package as services (why + * ResponseData is currently public); rename meta package to + */ + static final class ResponseData { + + final String name; + final String description; + final String status; + final String syntax; + final String deaggSyntax; + final Object server; + final Parameters parameters; + + ResponseData() { + this.name = "Source Models"; + this.description = "Installed source model listing"; + this.syntax = "%s://%s/nshmp-haz-ws/haz/{model}/{longitude}/{latitude}/{vs30}"; + this.deaggSyntax = "%s://%s/nshmp-haz-ws/deagg2/{model}/{longitude}/{latitude}/{imt}/{vs30}/{returnPeriod}/{basin}"; + this.status = Status.USAGE.toString(); + this.server = serverData(ServletUtil.THREAD_COUNT, ServletUtil.timer()); + this.parameters = new Parameters(); + } + } + + static class Parameters { + SourceModelsParameter models; + EnumParameter region; + DoubleParameter returnPeriod; + EnumParameter imt; + EnumParameter vs30; + + Parameters() { + models = new SourceModelsParameter( + "Source models", + ParamType.STRING, + Stream.of(Model.values()) + .map(SourceModel::new) + .collect(Collectors.toList())); + + region = new EnumParameter<>( + "Region", + ParamType.STRING, + EnumSet.allOf(Region.class)); + + returnPeriod = new DoubleParameter( + "Return period (in years)", + ParamType.NUMBER, + 100.0, + 1e6); + + imt = new EnumParameter<>( + "Intensity measure type", + ParamType.STRING, + modelUnionImts()); + + vs30 = new EnumParameter<>( + "Site soil (Vs30)", + ParamType.STRING, + modelUnionVs30s()); + } + } + + private static class SourceModelsParameter { + private final String label; + private final ParamType type; + private final List values; + + SourceModelsParameter(String label, ParamType type, List values) { + this.label = label; + this.type = type; + this.values = values; + } + } + + /* Union of IMTs across all models. */ + static Set modelUnionImts() { + return EnumSet.copyOf(Stream.of(Model.values()) + .flatMap(model -> model.imts.stream()) + .collect(Collectors.toSet())); + } + + /* Union of Vs30s across all models. */ + static Set modelUnionVs30s() { + return EnumSet.copyOf(Stream.of(Model.values()) + .flatMap(model -> model.vs30s.stream()) + .collect(Collectors.toSet())); + } + + static class SourceModel { + int displayorder; + int id; + String region; + String display; + String path; + String value; + String year; + ModelConstraints supports; + + SourceModel(Model model) { + this.display = model.name; + this.displayorder = model.ordinal(); + this.id = model.ordinal(); + this.region = model.region.name(); + this.path = model.path; + this.supports = new ModelConstraints(model); + this.value = model.toString(); + this.year = model.year; + } + } + + private static class ModelConstraints { + + final List imt; + final List vs30; + + ModelConstraints(Model model) { + this.imt = Util.enumsToNameList(model.imts); + this.vs30 = Util.enumsToStringList( + model.vs30s, + vs30 -> vs30.name().substring(3)); + } + } + + enum Attributes { + /* Source model service */ + MODEL, + + /* Serializing */ + ID, + VALUE, + DISPLAY, + DISPLAYORDER, + YEAR, + PATH, + REGION, + IMT, + VS30, + SUPPORTS, + MINLATITUDE, + MINLONGITUDE, + MAXLATITUDE, + MAXLONGITUDE; + + /** Return upper case string */ + String toUpperCase() { + return name().toUpperCase(); + } + + /** Return lower case string */ + String toLowerCase() { + return name().toLowerCase(); + } + } + + // TODO align with enum serializer if possible; consider service attribute + // enum + // TODO test removal of ui-min/max-lon/lat + static final class RegionSerializer implements JsonSerializer { + + @Override + public JsonElement serialize(Region region, Type typeOfSrc, JsonSerializationContext context) { + JsonObject json = new JsonObject(); + + json.addProperty(Attributes.VALUE.toLowerCase(), region.name()); + json.addProperty(Attributes.DISPLAY.toLowerCase(), region.toString()); + + json.addProperty(Attributes.MINLATITUDE.toLowerCase(), region.minlatitude); + json.addProperty(Attributes.MAXLATITUDE.toLowerCase(), region.maxlatitude); + json.addProperty(Attributes.MINLONGITUDE.toLowerCase(), region.minlongitude); + json.addProperty(Attributes.MAXLONGITUDE.toLowerCase(), region.maxlongitude); + + return json; + } + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/Util.java b/src/gov/usgs/earthquake/nshmp/www/Util.java new file mode 100644 index 000000000..201021f5e --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/Util.java @@ -0,0 +1,150 @@ +package gov.usgs.earthquake.nshmp.www; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import java.util.Arrays; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.servlet.ServletRequest; + +import gov.usgs.earthquake.nshmp.internal.Parsing; +import gov.usgs.earthquake.nshmp.internal.Parsing.Delimiter; + +public class Util { + + /** + * Returns the value of a servlet request parameter as a boolean. + * + * @param key of value to get + * @param request servlet request + */ + public static > boolean readBoolean(E key, ServletRequest request) { + return Boolean.valueOf(readValue(key, request)); + } + + /** + * Returns the value of a servlet request parameter as a double. + * + * @param key of value to get + * @param request servlet request + */ + public static > double readDouble(E key, ServletRequest request) { + return Double.valueOf(readValue(key, request)); + } + + /** + * Returns the value of a servlet request parameter as an integer. + * + * @param key of value to get + * @param request servlet request + */ + public static > int readInteger(E key, ServletRequest request) { + return Integer.valueOf(readValue(key, request)); + } + + /** + * Returns the value of a servlet request parameter as a string. + * + * @param key of value to get + * @param request servlet request + */ + public static > String readValue(E key, ServletRequest request) { + return readValues(key, request)[0]; + } + + /** + * Returns the value of a servlet request parameter as a enum of specified + * type. + * + * @param key of value to get + * @param request servlet request + * @param type of enum to return + */ + public static , E extends Enum> T readValue( + E key, + ServletRequest request, + Class type) { + return Enum.valueOf(type, readValue(key, request)); + } + + /** + * Returns the value of a servlet request parameter as a string array. + * + * @param key of value to get + * @param request servlet request + */ + public static > String[] readValues(E key, ServletRequest request) { + return checkNotNull( + request.getParameterValues(key.toString()), + "Missing query key [" + key.toString() + "]"); + } + + /** + * Returns the value of a servlet request parameter as a enum set of specified + * type. + * + * @param key of value to get + * @param request servlet request + * @param type of enum to return + */ + public static , E extends Enum> Set readValues( + E key, + ServletRequest request, + Class type) { + + return Arrays.stream(readValues(key, request)) + .map((name) -> Enum.valueOf(type, name)) + .collect(Collectors.toSet()); + } + + enum Key { + EDITION, + REGION, + MODEL, + VS30, + LATITUDE, + LONGITUDE, + IMT, + RETURNPERIOD, + DISTANCE, + FORMAT, + TIMESPAN, + BASIN; + + private String label; + + private Key() { + label = name().toLowerCase(); + } + + @Override + public String toString() { + return label; + } + } + + static > Set readValues(String values, Class type) { + return Parsing.splitToList(values, Delimiter.COMMA).stream() + .map((name) -> Enum.valueOf(type, name)) + .collect(Collectors.toSet()); + } + + static > String readValue(E key, Map paramMap) { + String keyStr = key.toString(); + String[] values = paramMap.get(keyStr); + checkNotNull(values, "Missing query key: %s", keyStr); + checkState(values.length > 0, "Empty value array for key: %s", key); + return values[0]; + } + + static , E extends Enum> T readValue( + E key, + Map paramMap, + Class type) { + return Enum.valueOf(type, readValue(key, paramMap)); + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/UtilitiesService.java b/src/gov/usgs/earthquake/nshmp/www/UtilitiesService.java new file mode 100644 index 000000000..dd9f44c26 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/UtilitiesService.java @@ -0,0 +1,131 @@ +package gov.usgs.earthquake.nshmp.www; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import gov.usgs.earthquake.nshmp.geo.json.Feature; +import gov.usgs.earthquake.nshmp.geo.json.GeoJson; +import gov.usgs.earthquake.nshmp.geo.json.Properties; +import gov.usgs.earthquake.nshmp.internal.NshmpSite; + +@WebServlet( + name = "Utilities Service", + description = "USGS NSHMP Web Service Utilities", + urlPatterns = { + "/util", + "/util/*" }) +@SuppressWarnings("javadoc") +public class UtilitiesService extends NshmpServlet { + + @Override + protected void doGet( + HttpServletRequest request, + HttpServletResponse response) + throws ServletException, IOException { + + PrintWriter out = response.getWriter(); + String utilUrl = "/nshmp-haz-ws/apps/util.html"; + + String pathInfo = request.getPathInfo(); + + switch (pathInfo) { + case "/testsites": + out.println(proccessTestSites()); + break; + default: + response.sendRedirect(utilUrl); + } + } + + private static String proccessTestSites() { + Map> nshmpSites = new HashMap<>(); + nshmpSites.put("ceus", NshmpSite.ceus()); + nshmpSites.put("cous", NshmpSite.cous()); + nshmpSites.put("wus", NshmpSite.wus()); + nshmpSites.put("ak", NshmpSite.alaska()); + nshmpSites.put("facilities", NshmpSite.facilities()); + nshmpSites.put("nehrp", NshmpSite.nehrp()); + nshmpSites.put("nrc", NshmpSite.nrc()); + nshmpSites.put("hawaii", NshmpSite.hawaii()); + + GeoJson.Builder builder = GeoJson.builder(); + + for (String regionKey : nshmpSites.keySet()) { + RegionInfo regionInfo = getRegionInfo(regionKey); + for (NshmpSite site : nshmpSites.get(regionKey)) { + Map properties = Properties.builder() + .put(Key.TITLE, site.toString()) + .put(Key.REGION_ID, regionInfo.regionId) + .put(Key.REGION_TITLE, regionInfo.regionDisplay) + .build(); + + builder.add(Feature.point(site.location()) + .id(site.id()) + .properties(properties) + .build()); + } + } + return builder.toJson(); + } + + private static class Key { + private static final String TITLE = "title"; + private static final String REGION_ID = "regionId"; + private static final String REGION_TITLE = "regionTitle"; + } + + private static class RegionInfo { + private String regionId; + private String regionDisplay; + + private RegionInfo(String regionDisplay, String regionId) { + this.regionId = regionId.toUpperCase(); + this.regionDisplay = regionDisplay; + } + + } + + private static RegionInfo getRegionInfo(String regionId) { + String regionDisplay = ""; + + switch (regionId) { + case "ceus": + regionDisplay = "Central & Eastern US"; + break; + case "cous": + regionDisplay = "Conterminous US"; + break; + case "wus": + regionDisplay = "Western US"; + break; + case "ak": + regionDisplay = "Alaska"; + break; + case "facilities": + regionDisplay = "US National Labs"; + break; + case "nehrp": + regionDisplay = "NEHRP"; + break; + case "nrc": + regionDisplay = "NRC"; + break; + case "hawaii": + regionDisplay = "Hawaii"; + break; + default: + throw new RuntimeException("Region [" + regionId + "] not found"); + } + + return new RegionInfo(regionDisplay, regionId); + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/XY_DataGroup.java b/src/gov/usgs/earthquake/nshmp/www/XY_DataGroup.java new file mode 100644 index 000000000..adb6213b5 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/XY_DataGroup.java @@ -0,0 +1,52 @@ +package gov.usgs.earthquake.nshmp.www; + +import java.util.ArrayList; +import java.util.List; + +import gov.usgs.earthquake.nshmp.data.XySequence; + +/** + * Container class of XY data sequences prior to Json serialization. This + * implementation is for data series that share the same x-values + * + * @author Peter Powers + */ +@SuppressWarnings("unused") +public class XY_DataGroup { + + private final String label; + private final String xLabel; + private final String yLabel; + protected final List data; + + protected XY_DataGroup(String label, String xLabel, String yLabel) { + this.label = label; + this.xLabel = xLabel; + this.yLabel = yLabel; + this.data = new ArrayList<>(); + } + + /** Create a data group. */ + public static XY_DataGroup create(String name, String xLabel, String yLabel) { + return new XY_DataGroup(name, xLabel, yLabel); + } + + /** Add a data sequence */ + public XY_DataGroup add(String id, String name, XySequence data) { + this.data.add(new Series(id, name, data)); + return this; + } + + static class Series { + private final String id; + private final String label; + private final XySequence data; + + Series(String id, String label, XySequence data) { + this.id = id; + this.label = label; + this.data = data; + } + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/Constrained.java b/src/gov/usgs/earthquake/nshmp/www/meta/Constrained.java new file mode 100644 index 000000000..2dbafe63b --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/Constrained.java @@ -0,0 +1,10 @@ +package gov.usgs.earthquake.nshmp.www.meta; + +/** + * Interface implemented by enum parameters that impose restrictions on other + * parameter choices. + */ +@SuppressWarnings("javadoc") +public interface Constrained { + public Constraints constraints(); +} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/Constraints.java b/src/gov/usgs/earthquake/nshmp/www/meta/Constraints.java new file mode 100644 index 000000000..449d3c7b8 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/Constraints.java @@ -0,0 +1,7 @@ +package gov.usgs.earthquake.nshmp.www.meta; + +/** + * Marker interface for supported parameter lists and ; individual enums provide + * concrete implementations. + */ +public interface Constraints {} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/DoubleParameter.java b/src/gov/usgs/earthquake/nshmp/www/meta/DoubleParameter.java new file mode 100644 index 000000000..0f4677d4c --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/DoubleParameter.java @@ -0,0 +1,27 @@ +package gov.usgs.earthquake.nshmp.www.meta; + +@SuppressWarnings({ "javadoc", "unused" }) +public final class DoubleParameter { + + private final String label; + private final ParamType type; + private final Values values; + + public DoubleParameter(String label, ParamType type, double min, double max) { + this.label = label; + this.type = type; + this.values = new Values(min, max); + } + + private final static class Values { + + final double minimum; + final double maximum; + + Values(double min, double max) { + this.minimum = min; + this.maximum = max; + } + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/Edition.java b/src/gov/usgs/earthquake/nshmp/www/meta/Edition.java new file mode 100644 index 000000000..dc77e1626 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/Edition.java @@ -0,0 +1,90 @@ +package gov.usgs.earthquake.nshmp.www.meta; + +import static gov.usgs.earthquake.nshmp.gmm.Imt.PGA; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA0P1; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA0P2; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA0P3; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA0P5; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA0P75; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA1P0; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA2P0; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA3P0; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA4P0; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA5P0; +import static gov.usgs.earthquake.nshmp.www.meta.Region.AK; +import static gov.usgs.earthquake.nshmp.www.meta.Region.CEUS; +import static gov.usgs.earthquake.nshmp.www.meta.Region.COUS; +import static gov.usgs.earthquake.nshmp.www.meta.Region.WUS; + +import java.util.EnumSet; +import java.util.Set; + +import gov.usgs.earthquake.nshmp.gmm.Imt; + +@SuppressWarnings({ "javadoc", "unused" }) +public enum Edition implements Constrained { + + E2008( + "Dynamic: Conterminous U.S. 2008", + 100, + EnumSet.of(COUS, CEUS, WUS), + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA0P75, SA1P0, SA2P0, SA3P0)), + + E2014( + "Dynamic: Conterminous U.S. 2014", + 0, + EnumSet.of(COUS, CEUS, WUS), + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA1P0, SA2P0)), + + E2014B( + "Dynamic: Conterminous U.S. 2014 (update)", + -10, + EnumSet.of(COUS, CEUS, WUS), + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA0P75, SA1P0, SA2P0, SA3P0, SA4P0, SA5P0)), + + E2007( + "Dynamic: Alaska 2007", + -100, + EnumSet.of(AK), + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA1P0, SA2P0)); + + private final String label; + + /* not serialized */ + private final transient String version; + private final transient Set regions; + final transient Set imts; + + private final Constraints constraints; + + final int displayOrder; + + private Edition( + String label, + int displayOrder, + Set regions, + Set imts) { + + this.version = Versions.modelVersion(name()); + this.label = label + " (" + version + ")"; + this.displayOrder = displayOrder; + this.regions = regions; + this.imts = imts; + this.constraints = new EditionConstraints(regions, imts); + } + + @Override + public String toString() { + return label; + } + + public String version() { + return version; + } + + @Override + public Constraints constraints() { + return constraints; + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/EditionConstraints.java b/src/gov/usgs/earthquake/nshmp/www/meta/EditionConstraints.java new file mode 100644 index 000000000..c0bec0d20 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/EditionConstraints.java @@ -0,0 +1,28 @@ +package gov.usgs.earthquake.nshmp.www.meta; + +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +import gov.usgs.earthquake.nshmp.calc.Vs30; +import gov.usgs.earthquake.nshmp.gmm.Imt; + +@SuppressWarnings("unused") +class EditionConstraints implements Constraints { + + private final List region; + private final List imt; + private final List vs30; + + EditionConstraints(Set regions, Set imts) { + // converting to Strings here, otherwise EnumSerializer will be used + // and we want a compact list of (possible modified) enum.name()s + this.region = Util.enumsToNameList(regions); + this.imt = Util.enumsToNameList(imts); + Set vs30s = EnumSet.noneOf(Vs30.class); + for (Region region : regions) { + vs30s.addAll(region.vs30s); + } + this.vs30 = Util.enumsToStringList(vs30s, vs30 -> vs30.name().substring(3)); + } +} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/EnumParameter.java b/src/gov/usgs/earthquake/nshmp/www/meta/EnumParameter.java new file mode 100644 index 000000000..39afa8147 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/EnumParameter.java @@ -0,0 +1,18 @@ +package gov.usgs.earthquake.nshmp.www.meta; + +import java.util.Set; + +@SuppressWarnings({ "javadoc", "unused" }) +public final class EnumParameter> { + + private final String label; + private final ParamType type; + private final Set values; + + public EnumParameter(String label, ParamType type, Set values) { + this.label = label; + this.type = type; + this.values = values; + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/Metadata.java b/src/gov/usgs/earthquake/nshmp/www/meta/Metadata.java new file mode 100644 index 000000000..2905d761c --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/Metadata.java @@ -0,0 +1,299 @@ +package gov.usgs.earthquake.nshmp.www.meta; + +import java.util.EnumSet; +import java.util.Set; + +import com.google.common.base.Throwables; +import com.google.common.collect.Sets; +import com.google.gson.annotations.SerializedName; + +import gov.usgs.earthquake.nshmp.calc.Vs30; +import gov.usgs.earthquake.nshmp.geo.Coordinates; +import gov.usgs.earthquake.nshmp.gmm.Imt; +import gov.usgs.earthquake.nshmp.mfd.Mfds; +import gov.usgs.earthquake.nshmp.www.ServletUtil; +import gov.usgs.earthquake.nshmp.www.ServletUtil.Timer; + +/** + * Service metadata, parameterization, and constraint strings, in JSON format. + */ +@SuppressWarnings("javadoc") +public final class Metadata { + + static final String NSHMP_HAZ_URL = "https://github.com/usgs/nshmp-haz"; + static final String NSHMP_HAZ_WS_URL = "https://github.com/usgs/nshmp-haz-ws"; + + /* + * The hazard service needs to report the list of all possible IMTs supported + * even though what can actually be supported is dependent on Edition and + * Region. When the slash delimited service returns 'any', the same logic + * applied on the client needs to be applied on the server to determine what + * 'any' acutally means. See HazardService.buildRequest() methods. This field + * currently set to the IMTs supported by Region.WUS which we know to be the + * union of all periods currently supported by the models used. + */ + private static final Set HAZARD_IMTS = Region.WUS.imts; + + private static final String URL_PREFIX = "%s://%s/nshmp-haz-ws"; + + public static final String HAZARD_USAGE = ServletUtil.GSON.toJson( + new Default( + "Compute hazard curve data at a location", + URL_PREFIX + "/hazard/{edition}/{region}/{longitude}/{latitude}/{imt}/{vs30}", + new HazardParameters())); + + public static final String DEAGG_USAGE = ServletUtil.GSON.toJson( + new Deagg( + "Deaggregate hazard at a location", + URL_PREFIX + + "/deagg/{edition}/{region}/{longitude}/{latitude}/{imt}/{vs30}/{returnPeriod}", + new DeaggParameters())); + + public static final String RATE_USAGE = ServletUtil.GSON.toJson( + new Rate( + "Compute incremental earthquake annual-rates at a location", + URL_PREFIX + "/rate/{edition}/{region}/{longitude}/{latitude}/{distance}", + new RateParameters())); + + public static final String PROBABILITY_USAGE = ServletUtil.GSON.toJson( + new Probability( + "Compute cumulative earthquake probabilities P(M ≥ x) at a location", + URL_PREFIX + + "/probability/{edition}/{region}/{longitude}/{latitude}/{distance}/{timespan}", + new ProbabilityParameters())); + + @SuppressWarnings("unused") + private static class Default { + + final String status; + final String description; + final String syntax; + final Object server; + final DefaultParameters parameters; + + private Default( + String description, + String syntax, + DefaultParameters parameters) { + this.status = Status.USAGE.toString(); + this.description = description; + this.syntax = syntax; + this.server = serverData(1, new Timer()); + this.parameters = parameters; + } + } + + public static Object serverData(int threads, Timer timer) { + return new Server(threads, timer); + } + + @SuppressWarnings("unused") + private static class Server { + + final int threads; + final String servlet; + final String calc; + + @SerializedName("nshmp-haz") + final Component nshmpHaz = NSHMP_HAZ_COMPONENT; + + @SerializedName("nshmp-haz-ws") + final Component nshmpHazWs = NSHMP_HAZ_WS_COMPONENT; + + Server(int threads, Timer timer) { + this.threads = threads; + this.servlet = timer.servletTime(); + this.calc = timer.calcTime(); + } + + static Component NSHMP_HAZ_COMPONENT = new Component( + NSHMP_HAZ_URL, + Versions.NSHMP_HAZ_VERSION); + + static Component NSHMP_HAZ_WS_COMPONENT = new Component( + NSHMP_HAZ_WS_URL, + Versions.NSHMP_HAZ_WS_VERSION); + + static final class Component { + + final String url; + final String version; + + Component(String url, String version) { + this.url = url; + this.version = version; + } + } + } + + @SuppressWarnings("unused") + private static class DefaultParameters { + + final EnumParameter edition; + final EnumParameter region; + final DoubleParameter longitude; + final DoubleParameter latitude; + + DefaultParameters() { + + edition = new EnumParameter<>( + "Model edition", + ParamType.STRING, + EnumSet.allOf(Edition.class)); + + region = new EnumParameter<>( + "Model region", + ParamType.STRING, + EnumSet.allOf(Region.class)); + + longitude = new DoubleParameter( + "Longitude (in decimal degrees)", + ParamType.NUMBER, + Coordinates.LON_RANGE.lowerEndpoint(), + Coordinates.LON_RANGE.upperEndpoint()); + + latitude = new DoubleParameter( + "Latitude (in decimal degrees)", + ParamType.NUMBER, + Coordinates.LAT_RANGE.lowerEndpoint(), + Coordinates.LAT_RANGE.upperEndpoint()); + } + } + + @SuppressWarnings("unused") + private static class HazardParameters extends DefaultParameters { + + final EnumParameter imt; + final EnumParameter vs30; + + HazardParameters() { + + imt = new EnumParameter<>( + "Intensity measure type", + ParamType.STRING, + HAZARD_IMTS); + + vs30 = new EnumParameter<>( + "Site soil (Vs30)", + ParamType.STRING, + EnumSet.allOf(Vs30.class)); + } + } + + private static class Deagg extends Default { + private Deagg( + String description, + String syntax, + DeaggParameters parameters) { + super(description, syntax, parameters); + } + } + + @SuppressWarnings("unused") + private static class DeaggParameters extends HazardParameters { + + final DoubleParameter returnPeriod; + + DeaggParameters() { + + returnPeriod = new DoubleParameter( + "Return period (in years)", + ParamType.NUMBER, + 1.0, + 4000.0); + } + } + + private static class Rate extends Default { + private Rate( + String description, + String syntax, + RateParameters parameters) { + super(description, syntax, parameters); + } + } + + @SuppressWarnings("unused") + private static class RateParameters extends DefaultParameters { + + final DoubleParameter distance; + + RateParameters() { + distance = new DoubleParameter( + "Cutoff distance (in km)", + ParamType.NUMBER, + 0.01, + 1000.0); + } + } + + private static class Probability extends Default { + private Probability( + String description, + String syntax, + ProbabilityParameters parameters) { + super(description, syntax, parameters); + } + } + + @SuppressWarnings("unused") + private static class ProbabilityParameters extends RateParameters { + + final DoubleParameter timespan; + + ProbabilityParameters() { + timespan = new DoubleParameter( + "Forecast time span (in years)", + ParamType.NUMBER, + Mfds.TIMESPAN_RANGE.lowerEndpoint(), + Mfds.TIMESPAN_RANGE.upperEndpoint()); + } + } + + public static String busyMessage(String url, long hits, long misses) { + Busy busy = new Busy(url, hits, misses); + return ServletUtil.GSON.toJson(busy); + } + + static final String BUSY_MESSAGE = "Server busy. Please try again later. " + + "We apologize for any inconvenience while we increase capacity."; + + private static class Busy { + + final String status = Status.BUSY.toString(); + final String request; + final String message; + + private Busy(String request, long hits, long misses) { + this.request = request; + this.message = BUSY_MESSAGE + String.format(" (%s,%s)", hits, misses); + } + } + + public static String errorMessage(String url, Throwable e, boolean trace) { + Error error = new Error(url, e, trace); + return ServletUtil.GSON.toJson(error); + } + + @SuppressWarnings("unused") + private static class Error { + + final String status = Status.ERROR.toString(); + final String request; + final String message; + + private Error(String request, Throwable e, boolean trace) { + this.request = request; + String message = e.getMessage() + " (see logs)"; + if (trace) { + message += "\n" + Throwables.getStackTraceAsString(e); + } + this.message = message; + } + } + + public static Set commonImts(Edition edition, Region region) { + return Sets.intersection(edition.imts, region.imts); + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/ParamType.java b/src/gov/usgs/earthquake/nshmp/www/meta/ParamType.java new file mode 100644 index 000000000..5d3e1475c --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/ParamType.java @@ -0,0 +1,13 @@ +package gov.usgs.earthquake.nshmp.www.meta; + +@SuppressWarnings("javadoc") +public enum ParamType { + INTEGER, + NUMBER, + STRING; + + @Override + public String toString() { + return name().toLowerCase(); + } +} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/Region.java b/src/gov/usgs/earthquake/nshmp/www/meta/Region.java new file mode 100644 index 000000000..a423db940 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/Region.java @@ -0,0 +1,121 @@ +package gov.usgs.earthquake.nshmp.www.meta; + +import static gov.usgs.earthquake.nshmp.calc.Vs30.VS_1150; +import static gov.usgs.earthquake.nshmp.calc.Vs30.VS_180; +import static gov.usgs.earthquake.nshmp.calc.Vs30.VS_2000; +import static gov.usgs.earthquake.nshmp.calc.Vs30.VS_259; +import static gov.usgs.earthquake.nshmp.calc.Vs30.VS_360; +import static gov.usgs.earthquake.nshmp.calc.Vs30.VS_537; +import static gov.usgs.earthquake.nshmp.calc.Vs30.VS_760; +import static gov.usgs.earthquake.nshmp.gmm.Imt.PGA; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA0P1; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA0P2; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA0P3; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA0P5; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA0P75; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA1P0; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA2P0; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA3P0; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA4P0; +import static gov.usgs.earthquake.nshmp.gmm.Imt.SA5P0; + +import java.util.EnumSet; +import java.util.Set; + +import gov.usgs.earthquake.nshmp.calc.Vs30; +import gov.usgs.earthquake.nshmp.gmm.Imt; + +@SuppressWarnings("javadoc") +public enum Region implements Constrained { + + AK( + "Alaska", + new double[] { 48.0, 72.0 }, + new double[] { -200.0, -125.0 }, + new double[] { 48.0, 72.0 }, + new double[] { -200.0, -125.0 }, + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA1P0, SA2P0), + EnumSet.of(VS_760)), + + COUS( + "Conterminous US", + new double[] { 24.6, 50.0 }, + new double[] { -125.0, -65.0 }, + new double[] { 24.6, 50.0 }, + new double[] { -125.0, -65.0 }, + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA1P0, SA2P0), + EnumSet.of(VS_760)), + + CEUS( + "Central & Eastern US", + new double[] { 24.6, 50.0 }, + new double[] { -115.0, -65.0 }, + new double[] { 24.6, 50.0 }, + new double[] { -100.0, -65.0 }, + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA1P0, SA2P0), + EnumSet.of(VS_2000, VS_760)), + + WUS( + "Western US", + new double[] { 24.6, 50.0 }, + new double[] { -125.0, -100.0 }, + new double[] { 24.6, 50.0 }, + new double[] { -125.0, -115.0 }, + EnumSet.of(PGA, SA0P1, SA0P2, SA0P3, SA0P5, SA0P75, SA1P0, SA2P0, SA3P0, SA4P0, SA5P0), + EnumSet.of(VS_1150, VS_760, VS_537, VS_360, VS_259, VS_180)); + + public final String label; + + public final double minlatitude; + public final double maxlatitude; + public final double minlongitude; + public final double maxlongitude; + + public final double uiminlatitude; + public final double uimaxlatitude; + public final double uiminlongitude; + public final double uimaxlongitude; + + /* not serialized */ + final transient Set imts; + final transient Set vs30s; + + private final Constraints constraints; + + private Region( + String label, + double[] latRange, + double[] lonRange, + double[] uiLatRange, + double[] uiLonRange, + Set imts, + Set vs30s) { + + this.label = label; + + this.minlatitude = latRange[0]; + this.maxlatitude = latRange[1]; + this.minlongitude = lonRange[0]; + this.maxlongitude = lonRange[1]; + + this.uiminlatitude = uiLatRange[0]; + this.uimaxlatitude = uiLatRange[1]; + this.uiminlongitude = uiLonRange[0]; + this.uimaxlongitude = uiLonRange[1]; + + this.imts = imts; + this.vs30s = vs30s; + + this.constraints = new RegionConstraints(imts, vs30s); + } + + @Override + public String toString() { + return label; + } + + @Override + public Constraints constraints() { + return constraints; + } +} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/RegionConstraints.java b/src/gov/usgs/earthquake/nshmp/www/meta/RegionConstraints.java new file mode 100644 index 000000000..604764612 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/RegionConstraints.java @@ -0,0 +1,21 @@ +package gov.usgs.earthquake.nshmp.www.meta; + +import java.util.List; +import java.util.Set; + +import gov.usgs.earthquake.nshmp.calc.Vs30; +import gov.usgs.earthquake.nshmp.gmm.Imt; + +@SuppressWarnings("unused") +class RegionConstraints implements Constraints { + + private final List imt; + private final List vs30; + + RegionConstraints(Set imts, Set vs30s) { + // converting to Strings here, otherwise EnumSerializer will be used + // and we want a compact list of (possible modified) enum.name()s + this.imt = Util.enumsToNameList(imts); + this.vs30 = Util.enumsToStringList(vs30s, vs30 -> vs30.name().substring(3)); + } +} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/Status.java b/src/gov/usgs/earthquake/nshmp/www/meta/Status.java new file mode 100644 index 000000000..f48acc512 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/Status.java @@ -0,0 +1,20 @@ +package gov.usgs.earthquake.nshmp.www.meta; + +/** + * Service request status identifier. + * + * @author Peter Powers + */ +@SuppressWarnings("javadoc") +public enum Status { + + BUSY, + ERROR, + SUCCESS, + USAGE; + + @Override + public String toString() { + return name().toLowerCase(); + } +} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/Util.java b/src/gov/usgs/earthquake/nshmp/www/meta/Util.java new file mode 100644 index 000000000..78449e579 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/Util.java @@ -0,0 +1,159 @@ +package gov.usgs.earthquake.nshmp.www.meta; + +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import com.google.common.collect.Range; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import gov.usgs.earthquake.nshmp.calc.Site; +import gov.usgs.earthquake.nshmp.calc.Vs30; +import gov.usgs.earthquake.nshmp.gmm.GmmInput; +import gov.usgs.earthquake.nshmp.gmm.GmmInput.Field; +import gov.usgs.earthquake.nshmp.util.Maths; + +@SuppressWarnings("javadoc") +public final class Util { + + public static > List enumsToNameList( + Collection values) { + return enumsToStringList(values, Enum::name); + } + + public static > List enumsToStringList( + Collection values, + Function function) { + return values.stream().map(function).collect(Collectors.toList()); + } + + public static final class EnumSerializer> implements JsonSerializer { + + @Override + public JsonElement serialize(E src, Type type, JsonSerializationContext context) { + + String value = (src instanceof Vs30) ? src.name().substring(3) : src.name(); + int displayOrder = (src instanceof Edition) ? ((Edition) src).displayOrder : src.ordinal(); + + JsonObject jObj = new JsonObject(); + jObj.addProperty("id", src.ordinal()); + jObj.addProperty("value", value); + if (src instanceof Edition) { + jObj.addProperty("version", ((Edition) src).version()); + } + jObj.addProperty("display", src.toString()); + jObj.addProperty("displayorder", displayOrder); + + if (src instanceof Region) { + Region region = (Region) src; + jObj.addProperty("minlatitude", region.minlatitude); + jObj.addProperty("maxlatitude", region.maxlatitude); + jObj.addProperty("minlongitude", region.minlongitude); + jObj.addProperty("maxlongitude", region.maxlongitude); + + jObj.addProperty("uiminlatitude", region.uiminlatitude); + jObj.addProperty("uimaxlatitude", region.uimaxlatitude); + jObj.addProperty("uiminlongitude", region.uiminlongitude); + jObj.addProperty("uimaxlongitude", region.uimaxlongitude); + } + + if (src instanceof Constrained) { + Constrained cSrc = (Constrained) src; + jObj.add("supports", context.serialize(cSrc.constraints())); + } + + return jObj; + } + } + + public static final class SiteSerializer implements JsonSerializer { + + @Override + public JsonElement serialize(Site site, Type typeOfSrc, JsonSerializationContext context) { + JsonObject loc = new JsonObject(); + + loc.addProperty("latitude", Maths.round(site.location.lat(), 3)); + loc.addProperty("longitude", Maths.round(site.location.lon(), 3)); + + JsonObject json = new JsonObject(); + json.add("location", loc); + json.addProperty("vs30", site.vs30); + json.addProperty("vsInfered", site.vsInferred); + json.addProperty("z1p0", Double.isNaN(site.z1p0) ? null : site.z1p0); + json.addProperty("z2p5", Double.isNaN(site.z2p5) ? null : site.z2p5); + + return json; + } + + } + + /* Constrain all doubles to 8 decimal places */ + public static final class DoubleSerializer implements JsonSerializer { + @Override + public JsonElement serialize(Double d, Type type, JsonSerializationContext context) { + double dOut = Double.valueOf(String.format("%.8g", d)); + return new JsonPrimitive(dOut); + } + } + + /* Serialize param type enum as lowercase */ + public static class ParamTypeSerializer implements JsonSerializer { + @Override + public JsonElement serialize(ParamType paramType, Type type, JsonSerializationContext context) { + return new JsonPrimitive(paramType.name().toLowerCase()); + } + } + + /* Convert NaN to null */ + public static final class NaNSerializer implements JsonSerializer { + @Override + public JsonElement serialize(Double d, Type type, JsonSerializationContext context) { + return Double.isNaN(d) ? null : new JsonPrimitive(d); + } + } + + public static final class ConstraintsSerializer implements JsonSerializer { + @Override + public JsonElement serialize( + GmmInput.Constraints constraints, + Type type, + JsonSerializationContext context) { + JsonArray json = new JsonArray(); + + for (Field field : Field.values()) { + Optional opt = constraints.get(field); + if (opt.isPresent()) { + Range value = (Range) opt.get(); + Constraint constraint = new Constraint( + field.id, + value.lowerEndpoint(), + value.upperEndpoint()); + json.add(context.serialize(constraint)); + } + } + + return json; + } + } + + private static class Constraint { + final String id; + final Object min; + final Object max; + + Constraint(String id, Object min, Object max) { + this.id = id; + this.min = min; + this.max = max; + } + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/Versions.java b/src/gov/usgs/earthquake/nshmp/www/meta/Versions.java new file mode 100644 index 000000000..dea9da6c7 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/Versions.java @@ -0,0 +1,55 @@ +package gov.usgs.earthquake.nshmp.www.meta; + +import com.google.common.collect.ImmutableMap; + +import java.io.InputStream; +import java.util.Map; +import java.util.Properties; + +import gov.usgs.earthquake.nshmp.HazardCalc; + +/* + * Application and model version data. References are string-based as opposed to + * enum-based (e.g. Edition) to avoid circular references in enum + * initializations. + */ +class Versions { + + + static final String NSHMP_HAZ_VERSION = HazardCalc.VERSION; + static final String NSHMP_HAZ_WS_VERSION; + private static final Map MODEL_VERSIONS; + private static final String UNKNOWN = "unknown"; + + static { + String nshmpHazWsVersion = UNKNOWN; + ImmutableMap.Builder modelMap = ImmutableMap.builder(); + + /* Always runs from a war (possibly unpacked). */ + try (InputStream in = Metadata.class.getResourceAsStream("/service.properties")){ + Properties props = new Properties(); + props.load(in); + in.close(); + + for (String key : props.stringPropertyNames()) { + String value = props.getProperty(key); + /* Web-services version. */ + if (key.equals("app.version")) { + nshmpHazWsVersion = value; + } + /* Model versions. */ + modelMap.put(key, value); + } + } catch (Exception e) { + /* Do nothing; probably running outside standard build. */ + } + + NSHMP_HAZ_WS_VERSION = nshmpHazWsVersion; + MODEL_VERSIONS = modelMap.build(); + } + + static String modelVersion(String id) { + return MODEL_VERSIONS.getOrDefault(id + ".version", UNKNOWN); + } + +} diff --git a/src/gov/usgs/earthquake/nshmp/www/meta/package-info.java b/src/gov/usgs/earthquake/nshmp/www/meta/package-info.java new file mode 100644 index 000000000..891b3a5f7 --- /dev/null +++ b/src/gov/usgs/earthquake/nshmp/www/meta/package-info.java @@ -0,0 +1,9 @@ +/** + * Web-service metadata support classes. + * + *

Classes in this package largely provide support for web services used on + * the public facing USGS website. Services that are not public facing may use a + * simpler metadata structure defined within the service class itself (e.g. + * {@link gov.usgs.earthquake.nshmp.www.SpectraService}. + */ +package gov.usgs.earthquake.nshmp.www.meta; From 1291aa65f3685aba2b3fb4da2d847f60743ad9fd Mon Sep 17 00:00:00 2001 From: bclayton-usgs Date: Thu, 12 Sep 2019 13:05:04 -0600 Subject: [PATCH 3/8] move over webapp --- webapp/META-INF/MANIFEST.MF | 1 + webapp/WEB-INF/web.xml | 54 + webapp/apps/config.json | 6 + webapp/apps/css/D3MapView.css | 97 + webapp/apps/css/D3View.css | 310 ++++ webapp/apps/css/Gmm.css | 176 ++ webapp/apps/css/MetadataPrint.css | 22 + webapp/apps/css/PrintFigure.css | 38 + webapp/apps/css/dashboard.css | 31 + webapp/apps/css/header.css | 20 + webapp/apps/css/location.css | 143 ++ webapp/apps/css/model-compare.css | 28 + webapp/apps/css/model-explorer.css | 27 + webapp/apps/css/services.css | 91 + webapp/apps/css/styles.css | 77 + webapp/apps/css/template.css | 610 ++++++ webapp/apps/css/test-sites.css | 13 + webapp/apps/css/util.css | 29 + webapp/apps/dynamic-compare.html | 173 ++ webapp/apps/exceedance-explorer.html | 166 ++ webapp/apps/geo-deagg.html | 151 ++ webapp/apps/gmm-distance.html | 253 +++ webapp/apps/hw-fw.html | 280 +++ webapp/apps/img/github.svg | 2 + webapp/apps/img/servicesIcon.png | Bin 0 -> 152531 bytes webapp/apps/img/usgs_logo 2.png | Bin 0 -> 5379 bytes webapp/apps/img/usgs_logo.png | Bin 0 -> 7503 bytes webapp/apps/js/Dashboard.js | 70 + webapp/apps/js/DashboardDev.js | 91 + webapp/apps/js/DynamicCompare.js | 1486 +++++++++++++++ webapp/apps/js/ExceedanceExplorer.js | 416 +++++ webapp/apps/js/GeoDeagg.js | 599 ++++++ webapp/apps/js/GmmDistance.js | 241 +++ webapp/apps/js/HwFw.js | 568 ++++++ webapp/apps/js/ModelCompare.js | 286 +++ webapp/apps/js/ModelExplorer.js | 394 ++++ webapp/apps/js/Services.js | 500 +++++ webapp/apps/js/Spectra.js | 1156 ++++++++++++ webapp/apps/js/Util.js | 34 + webapp/apps/js/calc/ExceedanceModel.js | 122 ++ webapp/apps/js/calc/Maths.js | 63 + webapp/apps/js/calc/UncertaintyModel.js | 33 + webapp/apps/js/d3/D3LinePlot.js | 1630 +++++++++++++++++ webapp/apps/js/d3/D3SaveFigure.js | 811 ++++++++ webapp/apps/js/d3/D3SaveLineData.js | 79 + webapp/apps/js/d3/D3Tooltip.js | 139 ++ webapp/apps/js/d3/D3Utils.js | 71 + webapp/apps/js/d3/axes/D3LineAxes.js | 418 +++++ webapp/apps/js/d3/data/D3LineData.js | 544 ++++++ webapp/apps/js/d3/data/D3LineSeriesData.js | 171 ++ webapp/apps/js/d3/data/D3XYPair.js | 43 + webapp/apps/js/d3/legend/D3LineLegend.js | 636 +++++++ .../js/d3/options/D3BaseSubViewOptions.js | 459 +++++ .../apps/js/d3/options/D3BaseViewOptions.js | 173 ++ .../apps/js/d3/options/D3LineLegendOptions.js | 509 +++++ webapp/apps/js/d3/options/D3LineOptions.js | 541 ++++++ .../js/d3/options/D3LineSubViewOptions.js | 811 ++++++++ .../apps/js/d3/options/D3LineViewOptions.js | 237 +++ .../apps/js/d3/options/D3SaveFigureOptions.js | 458 +++++ webapp/apps/js/d3/options/D3TextOptions.js | 282 +++ webapp/apps/js/d3/options/D3TooltipOptions.js | 329 ++++ webapp/apps/js/d3/view/D3BaseSubView.js | 186 ++ webapp/apps/js/d3/view/D3BaseView.js | 946 ++++++++++ webapp/apps/js/d3/view/D3LineSubView.js | 205 +++ webapp/apps/js/d3/view/D3LineView.js | 488 +++++ webapp/apps/js/error/NshmpError.js | 253 +++ webapp/apps/js/error/Preconditions.js | 417 +++++ webapp/apps/js/lib/Config.js | 51 + webapp/apps/js/lib/Constraints.js | 78 + webapp/apps/js/lib/ControlPanel.js | 1082 +++++++++++ webapp/apps/js/lib/D3GeoDeagg.js | 939 ++++++++++ webapp/apps/js/lib/D3SaveData.js | 96 + webapp/apps/js/lib/D3SaveFigure.js | 909 +++++++++ webapp/apps/js/lib/D3Tooltip.js | 220 +++ webapp/apps/js/lib/D3View.js | 1384 ++++++++++++++ webapp/apps/js/lib/Footer.js | 314 ++++ webapp/apps/js/lib/Gmm.js | 589 ++++++ webapp/apps/js/lib/GmmBeta.js | 455 +++++ webapp/apps/js/lib/Hazard.js | 565 ++++++ webapp/apps/js/lib/HazardNew.js | 237 +++ webapp/apps/js/lib/Header.js | 175 ++ webapp/apps/js/lib/LeafletTestSitePicker.js | 467 +++++ webapp/apps/js/lib/Settings.js | 179 ++ webapp/apps/js/lib/Spinner.js | 96 + webapp/apps/js/lib/TestSiteView.js | 284 +++ webapp/apps/js/lib/Tools.js | 417 +++++ .../apps/js/response/HazardServiceResponse.js | 395 ++++ webapp/apps/js/response/WebServiceResponse.js | 151 ++ webapp/apps/model-compare.html | 146 ++ webapp/apps/model-explorer.html | 145 ++ webapp/apps/services.html | 23 + webapp/apps/spectra-plot.html | 44 + webapp/apps/util.html | 85 + webapp/data/americas.json | 1 + webapp/data/data.tsv | 5 + webapp/data/us.json | 1 + webapp/dev/index.html | 44 + webapp/etc/examples/Dashboard.js | 80 + webapp/etc/examples/d3/D3BasicLinePlot.js | 112 ++ webapp/etc/examples/d3/D3CustomLinePlot.js | 244 +++ .../etc/examples/d3/d3-basic-line-plot.html | 40 + .../etc/examples/d3/d3-custom-line-plot.html | 40 + webapp/etc/examples/index.html | 43 + webapp/index.html | 44 + 104 files changed, 29873 insertions(+) create mode 100644 webapp/META-INF/MANIFEST.MF create mode 100644 webapp/WEB-INF/web.xml create mode 100644 webapp/apps/config.json create mode 100644 webapp/apps/css/D3MapView.css create mode 100644 webapp/apps/css/D3View.css create mode 100644 webapp/apps/css/Gmm.css create mode 100644 webapp/apps/css/MetadataPrint.css create mode 100644 webapp/apps/css/PrintFigure.css create mode 100644 webapp/apps/css/dashboard.css create mode 100644 webapp/apps/css/header.css create mode 100644 webapp/apps/css/location.css create mode 100644 webapp/apps/css/model-compare.css create mode 100644 webapp/apps/css/model-explorer.css create mode 100644 webapp/apps/css/services.css create mode 100644 webapp/apps/css/styles.css create mode 100644 webapp/apps/css/template.css create mode 100644 webapp/apps/css/test-sites.css create mode 100644 webapp/apps/css/util.css create mode 100644 webapp/apps/dynamic-compare.html create mode 100644 webapp/apps/exceedance-explorer.html create mode 100644 webapp/apps/geo-deagg.html create mode 100644 webapp/apps/gmm-distance.html create mode 100644 webapp/apps/hw-fw.html create mode 100644 webapp/apps/img/github.svg create mode 100644 webapp/apps/img/servicesIcon.png create mode 100644 webapp/apps/img/usgs_logo 2.png create mode 100644 webapp/apps/img/usgs_logo.png create mode 100644 webapp/apps/js/Dashboard.js create mode 100644 webapp/apps/js/DashboardDev.js create mode 100644 webapp/apps/js/DynamicCompare.js create mode 100644 webapp/apps/js/ExceedanceExplorer.js create mode 100644 webapp/apps/js/GeoDeagg.js create mode 100644 webapp/apps/js/GmmDistance.js create mode 100644 webapp/apps/js/HwFw.js create mode 100644 webapp/apps/js/ModelCompare.js create mode 100644 webapp/apps/js/ModelExplorer.js create mode 100644 webapp/apps/js/Services.js create mode 100644 webapp/apps/js/Spectra.js create mode 100644 webapp/apps/js/Util.js create mode 100644 webapp/apps/js/calc/ExceedanceModel.js create mode 100644 webapp/apps/js/calc/Maths.js create mode 100644 webapp/apps/js/calc/UncertaintyModel.js create mode 100644 webapp/apps/js/d3/D3LinePlot.js create mode 100644 webapp/apps/js/d3/D3SaveFigure.js create mode 100644 webapp/apps/js/d3/D3SaveLineData.js create mode 100644 webapp/apps/js/d3/D3Tooltip.js create mode 100644 webapp/apps/js/d3/D3Utils.js create mode 100644 webapp/apps/js/d3/axes/D3LineAxes.js create mode 100644 webapp/apps/js/d3/data/D3LineData.js create mode 100644 webapp/apps/js/d3/data/D3LineSeriesData.js create mode 100644 webapp/apps/js/d3/data/D3XYPair.js create mode 100644 webapp/apps/js/d3/legend/D3LineLegend.js create mode 100644 webapp/apps/js/d3/options/D3BaseSubViewOptions.js create mode 100644 webapp/apps/js/d3/options/D3BaseViewOptions.js create mode 100644 webapp/apps/js/d3/options/D3LineLegendOptions.js create mode 100644 webapp/apps/js/d3/options/D3LineOptions.js create mode 100644 webapp/apps/js/d3/options/D3LineSubViewOptions.js create mode 100644 webapp/apps/js/d3/options/D3LineViewOptions.js create mode 100644 webapp/apps/js/d3/options/D3SaveFigureOptions.js create mode 100644 webapp/apps/js/d3/options/D3TextOptions.js create mode 100644 webapp/apps/js/d3/options/D3TooltipOptions.js create mode 100644 webapp/apps/js/d3/view/D3BaseSubView.js create mode 100644 webapp/apps/js/d3/view/D3BaseView.js create mode 100644 webapp/apps/js/d3/view/D3LineSubView.js create mode 100644 webapp/apps/js/d3/view/D3LineView.js create mode 100644 webapp/apps/js/error/NshmpError.js create mode 100644 webapp/apps/js/error/Preconditions.js create mode 100644 webapp/apps/js/lib/Config.js create mode 100644 webapp/apps/js/lib/Constraints.js create mode 100644 webapp/apps/js/lib/ControlPanel.js create mode 100644 webapp/apps/js/lib/D3GeoDeagg.js create mode 100644 webapp/apps/js/lib/D3SaveData.js create mode 100644 webapp/apps/js/lib/D3SaveFigure.js create mode 100644 webapp/apps/js/lib/D3Tooltip.js create mode 100644 webapp/apps/js/lib/D3View.js create mode 100644 webapp/apps/js/lib/Footer.js create mode 100644 webapp/apps/js/lib/Gmm.js create mode 100644 webapp/apps/js/lib/GmmBeta.js create mode 100644 webapp/apps/js/lib/Hazard.js create mode 100644 webapp/apps/js/lib/HazardNew.js create mode 100644 webapp/apps/js/lib/Header.js create mode 100644 webapp/apps/js/lib/LeafletTestSitePicker.js create mode 100644 webapp/apps/js/lib/Settings.js create mode 100644 webapp/apps/js/lib/Spinner.js create mode 100644 webapp/apps/js/lib/TestSiteView.js create mode 100644 webapp/apps/js/lib/Tools.js create mode 100644 webapp/apps/js/response/HazardServiceResponse.js create mode 100644 webapp/apps/js/response/WebServiceResponse.js create mode 100644 webapp/apps/model-compare.html create mode 100644 webapp/apps/model-explorer.html create mode 100644 webapp/apps/services.html create mode 100644 webapp/apps/spectra-plot.html create mode 100644 webapp/apps/util.html create mode 100644 webapp/data/americas.json create mode 100644 webapp/data/data.tsv create mode 100644 webapp/data/us.json create mode 100644 webapp/dev/index.html create mode 100644 webapp/etc/examples/Dashboard.js create mode 100644 webapp/etc/examples/d3/D3BasicLinePlot.js create mode 100644 webapp/etc/examples/d3/D3CustomLinePlot.js create mode 100644 webapp/etc/examples/d3/d3-basic-line-plot.html create mode 100644 webapp/etc/examples/d3/d3-custom-line-plot.html create mode 100644 webapp/etc/examples/index.html create mode 100644 webapp/index.html diff --git a/webapp/META-INF/MANIFEST.MF b/webapp/META-INF/MANIFEST.MF new file mode 100644 index 000000000..2f4b56835 --- /dev/null +++ b/webapp/META-INF/MANIFEST.MF @@ -0,0 +1 @@ +Manifest-Version: 1.0 diff --git a/webapp/WEB-INF/web.xml b/webapp/WEB-INF/web.xml new file mode 100644 index 000000000..0b1a022f1 --- /dev/null +++ b/webapp/WEB-INF/web.xml @@ -0,0 +1,54 @@ + + + + nshmp-haz-ws + + + index.html + + + + preloadModels + false + + + + default + + listings + true + + + + + CorsFilter + org.apache.catalina.filters.CorsFilter + + + cors.support.credentials + false + + + + CorsFilter + /* + + + + ExpiresFilter + org.apache.catalina.filters.ExpiresFilter + + ExpiresDefault + access plus 15 minutes + + + + + ExpiresFilter + /* + REQUEST + + diff --git a/webapp/apps/config.json b/webapp/apps/config.json new file mode 100644 index 000000000..dfea3a2d5 --- /dev/null +++ b/webapp/apps/config.json @@ -0,0 +1,6 @@ +{ + "server": { + "static": "https://dev01-earthquake.cr.usgs.gov", + "dynamic": "" + } +} diff --git a/webapp/apps/css/D3MapView.css b/webapp/apps/css/D3MapView.css new file mode 100644 index 000000000..d5ac97f16 --- /dev/null +++ b/webapp/apps/css/D3MapView.css @@ -0,0 +1,97 @@ +/* +#################################################### +# +# D3MapView CSS +# +# +##################################################### +*/ + + +/* D3MapView */ +.D3MapView{ + position: relative; + height: 100%; +} + +/* Main map Bootstrap panel */ +.D3MapView>.panel{ + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: 0; +} + +/* Map: Bootstrap panel body */ +.D3MapView .map{ + height: 55%; + padding: 0; + background-color: #f5f5f5; +} + +/* Control panel: Bootstrap panel footer */ +.D3MapView .control-panel{ + position: absolute; + top: 55%; + right: 0; + bottom: 0; + left: 0; + padding: 0; + overflow: scroll; + background-color: white; +} + +/* All panels */ +.D3MapView .panel{ + margin: 0; +} + +/* Buttons in forms */ +.D3MapView .form-group .panel .btn{ + text-align: left; + border: none; +} + + +.D3MapView .col-xs-4{ + height: 100%; +} + +.D3MapView #test-site-form{ + height: 100%; + width: 100%; +} + +.D3MapView #test-site-form .panel{ + height: 90%; + overflow-x: scroll; + overflow-y: scroll; +} + +.D3MapView .btn-group-vertical{ + width: 100%; +} + + +.D3MapView .form-group{ + vertical-align: top; +} + +.D3MapView .form-control-panel{ + margin: 0; +} + + +.D3MapView form{ + position: absolute; + width: 100%; + height: 100%; + +} + + + + + diff --git a/webapp/apps/css/D3View.css b/webapp/apps/css/D3View.css new file mode 100644 index 000000000..5d6c80710 --- /dev/null +++ b/webapp/apps/css/D3View.css @@ -0,0 +1,310 @@ +/** +* CSS for D3View class +* +*/ + +/* D3 View */ +.D3View { + position: relative; +} + +/* Plot panel header */ +.D3View .panel .panel-heading { + background-color: #f7f7f7; + font-family: 'HelveticaNeue-Light',sans-serif; + position: relative; +} + +.D3View .panel-title { + font-size: 1.25em; +} + +.D3View .plot-title { + display: inline-block; + min-width: 30%; +} + +/* Plot panel body */ +.D3View .panel-body { + padding: 0; + line-height: 1.5; +} + +.D3View .panel-outer > .panel-body + .panel-body { + border-top: 1px solid #ddd; + position: relative; +} + +/* Plot panel footer */ +.D3View .panel .panel-footer { + background-color: #f7f7f7; + font-size: 1em; + position: relative; +} + +.D3View .footer-btn-toolbar { + margin: 0 1.25em 0 0; +} + +.D3View .footer-btn-group { + padding: 0 1%; +} + +.D3View .footer-button input { + height: 0; +} + +.D3View .panel-footer .dropdown-header { + font-weight: 400; +} + +.D3View .panel-footer label { + font-weight: initial; + font-size: 0.75em; +} + +/* Plot panel body data table */ +.D3View .panel-table td { + font-size: 0.75em; + vertical-align: initial; +} + +.D3View .panel-table th { + font-size: 1em; + font-weight: 550; + vertical-align: initial; + width: 50%; +} + +.D3View .panel-table tr tr td { + padding: 2px 5px; +} + +.D3View .panel-table .data-table-title { + font-size: 1.25em; + font-weight: 800; +} + +.D3View .panel-table { + overflow-x: scroll; + overflow-y: scroll; +} + +.D3View .panel-table .table { + margin-bottom: 2em; +} + +/* Panel glyphicons */ +.D3View .icon { + cursor: pointer; + font-size: 1em; + font-weight: 100; + line-height: 100%; + position: absolute; + right: 1em; + top: 50%; + transform: translateY(-50%); + z-index: 1000; +} + +.D3View .d3-tooltip { + line-height: 1.5; +} + +@media only screen and (max-width: 992px) { + .D3View .panel-footer label { + font-size: 0.45em; + } + + .D3View .plot-title { + font-size: 0.75em; + } + + .D3View .glyphicon { + font-size: 0.75em; + } + + .D3View .panel-heading { + padding: 5px 15px; + } + + .D3View .panel-footer { + padding: 5px 15px; + } +} + +@media only screen and (max-width: 768px) { + .D3View .panel-footer label { + font-size: 0.3em; + } + + .D3View .plot-title { + font-size: 0.5em; + } + + .D3View .panel-heading { + padding: 2.5px 7.5px; + } + + .D3View .panel-footer { + padding: 2.5px 7.5px; + } +} + +@media only screen and (min-width: 1450px) { + .D3View.col-xl-12 { + width: 100%; + } + .D3View.col-xl-11 { + width: 91.66666667%; + } + .D3View.col-xl-10 { + width: 83.33333333%; + } + .D3View.col-xl-9 { + width: 75%; + } + .D3View.col-xl-8 { + width: 66.66666667%; + } + .D3View.col-xl-7 { + width: 58.33333333%; + } + .D3View.col-xl-6 { + width: 50%; + } + .D3View.col-xl-5 { + width: 41.66666667%; + } + .D3View.col-xl-4 { + width: 33.33333333%; + } + .D3View.col-xl-3 { + width: 25%; + } + .D3View.col-xl-2 { + width: 16.66666667%; + } + .D3View.col-xl-1 { + width: 8.33333333%; + } + + .D3View.col-xl-offset-12 { + margin-left: 100%; + } + .D3View.col-xl-offset-11 { + margin-left: 91.66666667%; + } + .D3View.col-xl-offset-10 { + margin-left: 83.33333333%; + } + .D3View.col-xl-offset-9 { + margin-left: 75%; + } + .D3View.col-xl-offset-8 { + margin-left: 66.66666667%; + } + .D3View.col-xl-offset-7 { + margin-left: 58.33333333%; + } + .D3View.col-xl-offset-6 { + margin-left: 50%; + } + .D3View.col-xl-offset-5 { + margin-left: 41.66666667%; + } + .D3View.col-xl-offset-4 { + margin-left: 33.33333333%; + } + .D3View.col-xl-offset-3 { + margin-left: 25%; + } + .D3View.col-xl-offset-2 { + margin-left: 16.66666667%; + } + .D3View.col-xl-offset-1 { + margin-left: 8.33333333%; + } + .D3View.col-xl-offset-0 { + margin-left: 0; + } +} + +@media only screen and (min-width: 1700px) { + .D3View.col-xxl-12 { + width: 100%; + } + .D3View.col-xxl-11 { + width: 91.66666667%; + } + .D3View.col-xxl-10 { + width: 83.33333333%; + } + .D3View.col-xxl-9 { + width: 75%; + } + .D3View.col-xxl-8 { + width: 66.66666667%; + } + .D3View.col-xxl-7 { + width: 58.33333333%; + } + .D3View.col-xxl-6 { + width: 50%; + } + .D3View.col-xxl-5 { + width: 41.66666667%; + } + .D3View.col-xxl-4 { + width: 33.33333333%; + } + .D3View.col-xxl-3 { + width: 25%; + } + .D3View.col-xxl-2 { + width: 16.66666667%; + } + .D3View.col-xxl-1 { + width: 8.33333333%; + } + + .D3View.col-xxl-offset-12 { + margin-left: 100%; + } + .D3View.col-xxl-offset-11 { + margin-left: 91.66666667%; + } + .D3View.col-xxl-offset-10 { + margin-left: 83.33333333%; + } + .D3View.col-xxl-offset-9 { + margin-left: 75%; + } + .D3View.col-xxl-offset-8 { + margin-left: 66.66666667%; + } + .D3View.col-xxl-offset-7 { + margin-left: 58.33333333%; + } + .D3View.col-xxl-offset-6 { + margin-left: 50%; + } + .D3View.col-xxl-offset-5 { + margin-left: 41.66666667%; + } + .D3View.col-xxl-offset-4 { + margin-left: 33.33333333%; + } + .D3View.col-xxl-offset-3 { + margin-left: 25%; + } + .D3View.col-xxl-offset-2 { + margin-left: 16.66666667%; + } + .D3View.col-xxl-offset-1 { + margin-left: 8.33333333%; + } + .D3View.col-xxl-offset-0 { + margin-left: 0; + } +} diff --git a/webapp/apps/css/Gmm.css b/webapp/apps/css/Gmm.css new file mode 100644 index 000000000..725a3408c --- /dev/null +++ b/webapp/apps/css/Gmm.css @@ -0,0 +1,176 @@ +/* +#################################################### +# +# CSS for the Spectra and GmmDistance webpage +# +# +##################################################### +*/ + + +.D3View .fault-form .row{ + margin: 0; +} + +.D3View label{ + margin: 0; + font-weight: 700; +} + +.fault-form{ + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 65%; + border-left: 1px solid #ddd; + padding: 1em 0.5em 0.5em 0.5em; + height: 100%; + overflow: auto; + font-size: 0.80vw; +} + +.D3View .fault-form .form-control{ + height: 2vw; + font-size: 0.75em; + line-height: 1.5; + padding: 0 0.5em; +} + +.panel-lower .fault-form .input-group-addon{ + height: 2vw; + font-size: 0.75em; + line-height: 1; +} + +.panel-lower .fault-form [class*="col-"] { + padding: 0.2em; +} + +.D3View .fault-form .form-group{ + margin: 0; + padding: 0 0.25em; +} + +.D3View .fault-form .slider-form{ + margin-bottom: 0.50em; +} + +.slider{ + margin-top: 0.45em; + -webkit-appearance: none; + width: 100%; + height: 0.8em; + border-radius: 0.5em; + background: #eee; + border: 1px solid #ccc; +} + + +.slider:focus{ + outline: transparent; +} + +.slider::-webkit-slider-thumb{ + -webkit-appearance: none; + appearance: none; + height: 1em; + width: 1em; + border-radius: 50%; + background: #337ab7; + border-color: #2e6da4; + cursor: pointer; +} + +.slider::-webkit-slider-thumb:active{ + height: 1.35em; + width: 1.35em; +} + +.slider::-ms-fill-lower{ + background-color: #337ab7 +} + + +optgroup { + color: SteelBlue; +} + + +#gmm-sorter { + float: right; +} + + + +#addata { + float: right; + /*margin-top: 20px;*/ +} + + + +.form-group-sm .form-control { + height: 24px; + padding: 2px 4px; +} + +.input-group-sm .input-group-addon { + height: 24px; + padding: 2px 4px; +} + +#control [class*="col-sm-"], +#control [class*='col-xs-'] { + padding-left: 2px; + padding-right: 2px; +} + +.form-horizontal .form-group-sm .units { + text-align: left; + padding-top: 4px; +} + +.secondary-input { + font-weight: normal; +} + +.disabled { + color: #aaa; +} + +.btn-group { + padding-top: 1px; +} + + +/* plot */ + +/*text { + font: 10px sans-serif; +} +*/ + +.axis-label { + font-size: 1.2em; + font-weight: 500; + text-anchor: middle; +} + + +.axis path, +.axis line { + fill: none; + stroke: #000; + shape-rendering: crispEdges; +} + +.legend line, +.lines { + fill: none; + stroke-width: 2.5px; + stroke-linejoin: round; + stroke-linecap: round; +} + + diff --git a/webapp/apps/css/MetadataPrint.css b/webapp/apps/css/MetadataPrint.css new file mode 100644 index 000000000..b93cf8de1 --- /dev/null +++ b/webapp/apps/css/MetadataPrint.css @@ -0,0 +1,22 @@ +/* +* D3SaveMetadata printing css. +*/ + +@media print { + html { + margin: 0 !important; + padding: 0 !important; + } + + body { + margin: 0.25in !important; + padding: 0 !important; + } + + header, footer { + margin: 0; + padding: 0; + display: none !important; + } + +} diff --git a/webapp/apps/css/PrintFigure.css b/webapp/apps/css/PrintFigure.css new file mode 100644 index 000000000..620c14e33 --- /dev/null +++ b/webapp/apps/css/PrintFigure.css @@ -0,0 +1,38 @@ +/* +* Styling for printing in PDF or to printer +*/ + +body { + margin: 0; + padding: 0; +} + +@media print { + html { + height: 11in !important; + width: 8.5in !important; + margin: 0 !important; + padding: 0 !important; + transform: rotate(90deg); + -webkit-transform: rotate(90deg); + -moz-transform:rotate(90deg); + filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3); + } + + body { + margin: 0 !important; + padding: 0 !important; + } + + img { + margin: 0; + padding: 0; + } + + header, footer { + margin: 0; + padding: 0; + display: none !important; + } + +} diff --git a/webapp/apps/css/dashboard.css b/webapp/apps/css/dashboard.css new file mode 100644 index 000000000..876fe91dc --- /dev/null +++ b/webapp/apps/css/dashboard.css @@ -0,0 +1,31 @@ + + +#container { + position: fixed; + top: 0; + right:0; + bottom:0; + left:0; + margin: 2em 0 3.5em 0; + padding: 20px; + overflow: auto; + display: flex; +} + +#dash { + width: 100%; + margin: auto; +} + +.panel .panel-footer { + background-color: #f7f7f7; + font-family: 'HelveticaNeue-Light',sans-serif; +} + +.panel-body { + padding: 0; +} + +.panel:hover { + cursor: pointer; +} diff --git a/webapp/apps/css/header.css b/webapp/apps/css/header.css new file mode 100644 index 000000000..7fc1943a6 --- /dev/null +++ b/webapp/apps/css/header.css @@ -0,0 +1,20 @@ + +#header { + background: black url('../img/usgs_logo.png') 0px/84px no-repeat; + background-position: 3px 3px; + position: fixed; + z-index: 100; + top: 0; + left: 0; + width: 100%; + height: 30px; + padding-right: 8px; + font-size: 18px; + font-weight: 300; + color: white; + text-align: right; + line-height: 30px; + box-sizing: border-box; + box-shadow: 0px 0px 3px 2px #666666; + -webkit-font-smoothing: subpixel-antialiased; +} diff --git a/webapp/apps/css/location.css b/webapp/apps/css/location.css new file mode 100644 index 000000000..9d95fc90a --- /dev/null +++ b/webapp/apps/css/location.css @@ -0,0 +1,143 @@ +/* +#################################################### +# +# Location CSS +# +# +##################################################### +*/ + + +/*###################################################*/ +/**/ +/*................... Main Content ..................*/ + +#content{ + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + margin: 2em 0 3.5em 0; +} +/*-------------- End: Main Content -------------------*/ +/**/ +/*###################################################*/ + + + +/* +#testsite-menu{ + width: 100%; +} + +#menu-text{ + float: left; +} + +.caret{ + float:right; +} + + + +#testsite>li{ + padding-left: 10px; +} + +*/ + + +.list-group-item{ + font-size: 12px; + padding: 5px 10px; +} + +#region{ + width: 100%; + font-size: 12px; +} + + +#testsite{ + height: 250px; + overflow-x: scroll; + padding: 0; +} + +#testsite{ + width: 100%; +} + +#testsite>label{ + font-size: 11px; +} + + +#map-checkbox{ + position: absolute; + bottom: 20px; + padding: 10px 0; +} + + +#inputs, +.form-horizontal{ + height: 100%; +} + + +/*#################################################*/ +/**/ +/*.................... Plots ......................*/ + + +/*............... Plot Panel Container ............*/ +#content>.map-panel{ + position: absolute; + padding: 20px 0 20px 20px; + top:0; + right: 300px; + bottom: 0; + left:0; +} +/*-------------------------------------------------*/ + +/*............... Plot Panel Container ............*/ +#content>.control-panel{ + position: absolute; + padding: 20px 20px 20px 0; + top:0; + right: 0; + bottom: 0; + width: 300px; +} +/*-------------------------------------------------*/ + +#map{ + margin: 0 auto; + padding: 0; +} + +.panel, +.panel-body{ + height: 100%; +} + +#content>.map-panel>.panel{ + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; +} + +#content>.control-panel>.panel{ + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + background-color: #f7f7f7; +} + +/*------------- End: Plots ------------------------*/ +/**/ +/*#################################################*/ + + + diff --git a/webapp/apps/css/model-compare.css b/webapp/apps/css/model-compare.css new file mode 100644 index 000000000..cba383ac3 --- /dev/null +++ b/webapp/apps/css/model-compare.css @@ -0,0 +1,28 @@ +/* +#################################################### +# +# CSS for the model-compare webpage +# +# +##################################################### +*/ + + + + +/*#################################################*/ +/**/ +/*............... Lat/Lon Bounds ..................*/ + +#lat-bounds, +#lon-bounds{ + color: black; + opacity: 0.8; +} +/*------------- End: Lat/Lon Bounds ---------------*/ +/**/ +/*#################################################*/ + + + + diff --git a/webapp/apps/css/model-explorer.css b/webapp/apps/css/model-explorer.css new file mode 100644 index 000000000..3b7071a34 --- /dev/null +++ b/webapp/apps/css/model-explorer.css @@ -0,0 +1,27 @@ +/* +#################################################### +# +# CSS for the model-explorer webpage +# +# +##################################################### +*/ + + + +/*#################################################*/ +/**/ +/*............... Lat/Lon Bounds ..................*/ + +#lat-bounds, +#lon-bounds{ + color: black; + opacity: 0.8; +} +/*------------- End: Lat/Lon Bounds ---------------*/ +/**/ +/*#################################################*/ + + + + diff --git a/webapp/apps/css/services.css b/webapp/apps/css/services.css new file mode 100644 index 000000000..fa7b14fb8 --- /dev/null +++ b/webapp/apps/css/services.css @@ -0,0 +1,91 @@ +/* CSS for services.html and Services.js */ + + +/* Service panel heading */ +.panel .panel-heading>* { + margin-bottom: 0; + margin-top: 0; +} + +/* Lists inside service panels */ +.service .list-group-item { + border: none; + padding: 0.25em 1em; +} + +/* Containers */ +.services { + overflow: scroll; +} + +.content { + margin-top: 2.5em; +} + +/* Service nav menu */ +.service-menu { + padding-left: 3em; + padding-top: 2em; +} + +.service-menu-list>li>a { + color: #767676;; + font-weight: 400; + line-height: 1.25; + padding: 0.25em; +} + +.service-menu .back-to-top { + padding-top: 1.25em; +} + +.service-menu .back-to-top>a { + color: #999; +} + +/* Service panel */ +.anchor { + padding-top: 2em; +} +.service { + padding-top: 2.5em; +} + +.service:last-of-type { + padding-bottom: 5em; +} + +.service .service-div>* { + margin-top: 0; +} + +.service .service-div { + padding: 0.5em 0; +} + +/* Service panel footer */ +.panel-footer { + background-color: white; +} + +.examples { + color: #959595; + font-size: 12px; + font-weight: 700; + letter-spacing: 1px; + padding-bottom: 0.5em; + text-transform: uppercase; +} + +/* URL formats */ +.format-url { + padding-bottom: 6px; + word-break: break-all; + word-wrap: break-word; +} + +.service-link { + padding-bottom: 6px; + word-break: break-all; + word-wrap: break-word; +} diff --git a/webapp/apps/css/styles.css b/webapp/apps/css/styles.css new file mode 100644 index 000000000..7459906a8 --- /dev/null +++ b/webapp/apps/css/styles.css @@ -0,0 +1,77 @@ +body { + margin: 36px 12px 12px 12px; +} + +/* +h1, h2, h3 { + font-weight: 500; +} +*/ + +#header { + background: black url('../img/usgs_logo.png') 0px/84px no-repeat; + background-position: 3px 3px; + position: fixed; + z-index: 100; + top: 0; + left: 0; + width: 100%; + height: 30px; + padding-right: 8px; + font-size: 18px; + font-weight: 300; + color: white; + text-align: right; + line-height: 30px; + box-sizing: border-box; + box-shadow: 0px 0px 3px 2px #666666; + -webkit-font-smoothing: subpixel-antialiased; +} + +#status, #model1, #model2 { + float: right; + display: inline-block; + padding: 6px 12px; + margin-bottom: 12px; + clear: right; +} + +.service { + width: 640px; + margin-bottom: 24px; + border-bottom: 1px solid #ddd; +} + +.service:last-of-type { + border: 0px; +} + +.serviceExample { + position: relative; + padding: 45px 15px 0px; + margin: 20px 32px 20px 10px; + border: 1px solid #e5e5e5; + border-radius: 4px; + -webkit-box-shadow: inset 0 3px 6px rgba(0, 0, 0, .05); + box-shadow: inset 0 3px 6px rgba(0, 0, 0, .05) +} + +.serviceExample:after { + position: absolute; + top: 15px; + left: 15px; + font-size: 12px; + font-weight: 700; + color: #959595; + text-transform: uppercase; + letter-spacing: 1px; + content: "Examples"; + box-sizing: border-box; +} + +.serviceLink { + word-wrap: break-word; + word-break: break-all; + padding-bottom: 6px; +} + diff --git a/webapp/apps/css/template.css b/webapp/apps/css/template.css new file mode 100644 index 000000000..92fb3174e --- /dev/null +++ b/webapp/apps/css/template.css @@ -0,0 +1,610 @@ +/* +###################################################### +# +# Main CSS for webapps with a control panel: +# - spectra-plot +# - model-explorer +# +###################################################### +*/ + + +/* Test Site View Class */ + +.test-site-view.in { + display: flex !important; +} + +.test-site-view .modal-body { + padding: 0 15px; + height: 200px; +} + +.test-site-view .modal-dialog { + margin: auto; + padding: 1em; +} + +@media (min-width: 1200px) { + .test-site-view .modal-lg { + width: 1100px; + } +} + +@media (max-width: 768px) { + .test-site-view .modal-dialog { + width: 100%; + } + + .test-site-view #site-list label { + font-size: 0.5em; + } +} + +@media (min-height: 500px) { + .test-site-view .modal-body { + padding: 0 15px; + height: 300px; + } +} + +@media (min-height: 700px) { + .test-site-view .modal-body { + padding: 0 15px; + height: 500px; + } +} + +@media (min-height: 900px) { + .test-site-view .modal-body { + padding: 0 15px; + height: 700px; + } +} + +.test-site-view #map-body { + bottom: 0; + left: 0; + position: absolute; + top: 0; +} + +.test-site-view #site-list-body { + bottom: 0; + overflow: scroll; + position: absolute; + right: 0; + top: 0; +} + +.test-site-view #site-list-body .form-group { + margin: 0; +} + +.test-site-view #site-list { + width: 100%; +} + +/*###################################################*/ +/**/ +/*.................... Main Body ....................*/ + +html, body{ + width: 100%; + height:100%; + -webkit-font-smoothing: subpixel-antialiased; +} +/*------------------ End: Main Body -----------------*/ +/**/ +/*###################################################*/ + + +.vertical-center { + top: 50% !important; + transform: translate(0, -50%) !important; +} + + +/*###################################################*/ +/**/ +/*..................... Header ......................*/ + +/*..................... Header ......................*/ +#header{ + background: black url("../img/usgs_logo.png") 0px/84px no-repeat; + background-position: 3px 3px; + position: fixed; + color: white; + z-index: 100; + top: 0; + left: 0; + right: 0; + height: 1.6em; + line-height: 1.6em; + text-align: right; + padding-right: 8px; + font-weight: 300; + font-size: 18px; + box-sizing: border-box; + box-shadow: 0px 0px 3px 2px #999; +} +/*---------------------------------------------------*/ + + +/*................. Header Title ....................*/ +#header .title{ + padding-right: 8px; +} +/*---------------------------------------------------*/ + + +/*................. Header Menu .....................*/ +#header #header-menu{ + float:right; +} +/*---------------------------------------------------*/ + + +/*........... Header Glyphicon Hover.................*/ +#header .glyphicon-menu-hamburger:hover{ + cursor:pointer; +} +/*---------------------------------------------------*/ + + +/*............. Header Glyphicon ....................*/ +#header .glyphicon{ + top: 3px; +} +/*---------------------------------------------------*/ + +/*-------------- End: Header ------------------------*/ +/**/ +/*###################################################*/ + + + + +/*###################################################*/ +/**/ +/* .................... Footer ..................... */ + +.Footer{ + position: absolute; + left: 0; + right: 0; + bottom: 0; + background-color: #f7f7f7; + border-top: 1px solid #bbb; + box-shadow: 0px 0px 3px 1px #ccc; + z-index: 101; + height: 3.5em; +} + +.Footer .settings-btn{ + padding-left: 10px; + cursor: pointer; +} + +.Footer .footer-btns { + position: absolute; + left: 8em; + right: 8em; + top: 50%; + transform: translateY(-50%); +} + +.Footer .footer-icons { + position: absolute; + left: calc(100% - 8em); + top: 50%; + transform: translateY(-50%); +} + +.Footer .code-info-icon { + cursor: pointer; + font-size: 2.0em; + vertical-align: middle; + margin: 0 0.5em; +} + +.Footer .github-icon { + cursor: pointer; + width: 2.0em; + margin: 0 0.5em; +} + +.Footer .code-info-icon:focus { + outline: none; +} + +.Footer .code-info-collapse { + bottom: 100%; + left: 400px; + position: absolute; + right: 0; +} + +.Footer .well { + background-color: white; + margin: 10px 2px 3px 2px; + padding: 10px; +} + +.Footer .code-info-icon.disabled { + color: grey; + cursor: not-allowed; + opacity: 0.65; +} + +/*-------------- End: Footer ----------------------- */ +/**/ +/*###################################################*/ + + + + +/*###################################################*/ +/**/ +/*.................. Control Panel ..................*/ + +/*.................. Control Panel ..................*/ +#control { + background-color: #f7f7f7; + position: fixed; + top: 0; + left: 0; + bottom: 0; + margin: 2em 0 3.5em 0; + padding:20px; + width: 400px; + overflow: auto; + border-right: 1px solid #ddd; +} + +#control .btn-group .btn+.btn { + margin-left: 0; +} + +/*.................... Forms .......................*/ +#control .form-horizontal .form-group { + margin-right: -5px; + margin-left: -5px; + margin-bottom: 6px; + padding: 0.25em; +} + + +#control option:disabled { + color:#999; +} + +#control option { + color: black; +} + + +label { + font-weight: 500; +} + +label.control-group { + font-weight: 700; +} + + +label.control-spacer { + margin-top: 8px; +} + +#control .form-inline label small { + font-weight: normal; +} + +#control .form-horizontal .form-control { + font-size: 12px; +} + +#control .form-horizontal .form-group-sm .control-label { + padding-top: 3px; +} + +#control .control-panel-slider { + margin-top: 0.45em; + -webkit-appearance: none; + appearance: none; + height: 0.8em; + border-radius: 0.5em; + background: #eee; + border: 1px solid #ccc; +} + +#control .control-panel-slider:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +#control .control-panel-slider:focus { + outline: transparent; +} + +#control .control-panel-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + height: 1em; + width: 1em; + border-radius: 50%; + background: #337ab7; + border-color: #2e6da4; + cursor: pointer; +} + +#control .control-panel-slider::-webkit-slider-thumb:active { + height: 1.35em; + width: 1.35em; +} + +#control .control-panel-slider::-ms-fill-lower { + background-color: #337ab7 +} + +#control .btn-group>.btn.focus { + background-color: initial; +} + +#control .btn-group>.btn.focus.active { + background-color: #d4d4d4; +} +/*----------- End: Control Panel -------------------*/ +/**/ +/*###################################################*/ + + + + +/*###################################################*/ +/**/ +/*................... Main Content ..................*/ + +#content{ + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + margin: 2em 0 3.5em 400px; + overflow: auto; + padding: 20px 0; +} +/*-------------- End: Main Content -------------------*/ +/**/ +/*###################################################*/ + + + +/*#########################################################*/ +/**/ +/*................... Setting Menu .......................*/ + + +/*............. Screen Overlay ......................*/ +.Settings .settings-overlay{ + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: black; + opacity: 0.20; + z-index: 1000; +} +/*---------------------------------------------------*/ + +/*............. Loader Box to Put Spinner ...........*/ +.Settings .settings-panel{ + position: absolute; + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); + height: 50%; + width: 50%; + box-sizing: border-box; + box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); + z-index: 1001; + background-color: white; +} +/*---------------------------------------------------*/ + +.Settings .settings-panel .panel-heading{ + height: 2.5em; +} + +.Settings .settings-panel .panel-footer{ + height: 3.5em; + padding: 8px; +} + +.Settings .settings-panel .panel-body{ + height: calc(100% - 6em); + padding: 15px; +} + + +/*-------------- End: Setting Menu -----------------------*/ +/**/ +/*########################################################*/ + + + + +/*###################################################*/ +/**/ +/*................... Spinner .......................*/ + +.test-site-modal.in { + display: flex !important; +} + +.test-site-modal .modal-dialog { + display: flex; + flex-direction: column; + justify-content: center; +} + +.Spinner { + display: flex !important; +} + +.Spinner .modal-dialog { + margin: auto; + width: auto; +} + +.Spinner .spinner-text { + font-size: 1.5em; + padding: 1em 0 0 0; + margin: 0; +} + +.Spinner .modal-content { + height: auto; + width: 12em; +} + +.Spinner .modal-content { + text-align: center; +} + +.Spinner .modal-footer { + text-align: center; +} + +.Spinner .loading-spinner { + margin: 0.25em auto; + border: 0.7em solid #E0E0E0; + border-radius: 50%; + border-top: 0.7em solid #337ab7; + width: 4em; + height: 4em; + -webkit-animation: spin 2s linear infinite; + animation: spin 2s linear infinite; +} + +@-webkit-keyframes spin { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(360deg); } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} +/*-------------- End: Spinner -----------------------*/ +/**/ +/*###################################################*/ + + + + + +/*###################################################*/ +/**/ +/*................... Axes Buttons ..................*/ + + +.axes-btns>.form-group{ + margin: 0 2%; + font-size: 12px; +} + +.axes-btns>.form-group>.btn-group>.btn{ + width: 55px; + margin: 5px 0; + text-align: center; +} + +/*-------------- End: Axes Buttons ------------------*/ +/**/ +/*###################################################*/ + + + + +/*#################################################*/ +/**/ +/*.................... Plots ......................*/ + + + +/*............... Plot Panel Container ............*/ +#content>.plot-panel{ + position: relative; + height: 100%; + padding:20px; +} +/*-------------------------------------------------*/ + + +/*............... Plot Panel ......................*/ +#content>.plot-panel>.panel{ + position: relative; + height: 100%; +} +/*-------------------------------------------------*/ + + +/*............... Plot Panel Header ...............*/ +#content>.plot-panel>.panel>.panel-heading{ + background-color: #f7f7f7; + font-family: 'HelveticaNeue-Light',sans-serif; + font-size: 1.25em; +} +/*-------------------------------------------------*/ + + +/*............... Plot Panel Content ...............*/ +#content>.plot-panel>.panel>.panel-content{ + position: relative; + height: 100%; + margin: 0 auto; +} +#content>.plot-panel>.panel>.panel-body{ + position: relative; + height: 100%; + margin: 0 auto; + padding: 0; +} +/*-------------------------------------------------*/ + + +/*............... Plot Panel Footer ...............*/ +#content>.plot-panel>.panel>.panel-footer{ + background-color: #f7f7f7; + position: absolute; + right: 0; + bottom: 0; + left: 0; + padding: 5px 0; +} +/*-------------------------------------------------*/ + + +/*............... Plot Resize ....................*/ +#content>.plot-panel>.panel>.panel-heading>.plot-resize{ + float: right; + font-size: .85em; + font-weight: 100; + opacity: .7; +} + +#content>.plot-panel>.panel>.panel-heading>.plot-resize:hover{ + cursor: pointer; +} +/*------------------------------------------------*/ + + +/*------------- End: Plots ------------------------*/ +/**/ +/*#################################################*/ + + diff --git a/webapp/apps/css/test-sites.css b/webapp/apps/css/test-sites.css new file mode 100644 index 000000000..c1e397865 --- /dev/null +++ b/webapp/apps/css/test-sites.css @@ -0,0 +1,13 @@ + + + + +#testsites{ + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: 2em 0 3.5em 0; + overflow: auto; +} diff --git a/webapp/apps/css/util.css b/webapp/apps/css/util.css new file mode 100644 index 000000000..22ed20171 --- /dev/null +++ b/webapp/apps/css/util.css @@ -0,0 +1,29 @@ +/* +#################################################### +# +# Utilities CSS +# +# +##################################################### +*/ + + +/*###################################################*/ +/**/ +/*................... Main Content ..................*/ + +#content{ + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + margin: 2em 0 3.5em 0; + padding: 20px; + overflow: auto; +} +/*-------------- End: Main Content -------------------*/ +/**/ +/*###################################################*/ + + diff --git a/webapp/apps/dynamic-compare.html b/webapp/apps/dynamic-compare.html new file mode 100644 index 000000000..c3d92905f --- /dev/null +++ b/webapp/apps/dynamic-compare.html @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + +

+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + diff --git a/webapp/apps/exceedance-explorer.html b/webapp/apps/exceedance-explorer.html new file mode 100644 index 000000000..283aae99d --- /dev/null +++ b/webapp/apps/exceedance-explorer.html @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + diff --git a/webapp/apps/geo-deagg.html b/webapp/apps/geo-deagg.html new file mode 100644 index 000000000..588f22572 --- /dev/null +++ b/webapp/apps/geo-deagg.html @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + diff --git a/webapp/apps/gmm-distance.html b/webapp/apps/gmm-distance.html new file mode 100644 index 000000000..24e1f4f6c --- /dev/null +++ b/webapp/apps/gmm-distance.html @@ -0,0 +1,253 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webapp/apps/hw-fw.html b/webapp/apps/hw-fw.html new file mode 100644 index 000000000..253f3bd8f --- /dev/null +++ b/webapp/apps/hw-fw.html @@ -0,0 +1,280 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webapp/apps/img/github.svg b/webapp/apps/img/github.svg new file mode 100644 index 000000000..1438915a8 --- /dev/null +++ b/webapp/apps/img/github.svg @@ -0,0 +1,2 @@ + + diff --git a/webapp/apps/img/servicesIcon.png b/webapp/apps/img/servicesIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..630125724688ec2f3d8509f811dfdc2c17c337d5 GIT binary patch literal 152531 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 NMnXZn{I$`?{{vu=^xXge literal 0 HcmV?d00001 diff --git a/webapp/apps/img/usgs_logo 2.png b/webapp/apps/img/usgs_logo 2.png new file mode 100644 index 0000000000000000000000000000000000000000..00df1785f1e2627c9d18bd403162bbd8d7e02c9b GIT binary patch literal 5379 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 literal 0 HcmV?d00001 diff --git a/webapp/apps/img/usgs_logo.png b/webapp/apps/img/usgs_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ef1acfc8b56a21193dffde59f930213c5bc87fe0 GIT binary patch literal 7503 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 { window.location = d.href; }) + .append('div') + .attr('class', 'panel panel-default') + .append('div') + .attr('class', 'panel-heading') + .append('h2') + .attr('class', 'panel-title') + .text((d, i) => {return d.label;}); + } + +} diff --git a/webapp/apps/js/DashboardDev.js b/webapp/apps/js/DashboardDev.js new file mode 100644 index 000000000..0c47f2800 --- /dev/null +++ b/webapp/apps/js/DashboardDev.js @@ -0,0 +1,91 @@ +'use strict'; + +import Footer from './lib/Footer.js'; +import Header from './lib/Header.js'; +import Settings from './lib/Settings.js'; + +/** +* @fileoverview Class for index.html, this class +* creates the header and footer for the index page. +* +* @class Dashboard +* @author bclayton@usgs.gov +*/ +export default class Dashboard { + + /** + * @param {Object} config - config.json object, output from + * Config.getConfig() + */ + constructor(config) { + /** @type {Footer} */ + this.footer = new Footer().removeButtons().removeInfoIcon(); + /** @type {Settings} */ + //this.settings = new Settings(footer.settingsBtnEl); + /** @type {Header} */ + this.header = new Header(); + this.header.setTitle("Dev Dashboard"); + + /** @ type {Array{Object}} */ + this.webapps = [ + { + label: 'Dynamic Model Comparison', + href: '../apps/dynamic-compare.html', + }, { + label: 'Response Spectra', + href: '../apps/spectra-plot.html', + }, { + label: 'Model Comparison', + href: '../apps/model-compare.html', + }, { + label: 'Ground Motion Vs. Distance', + href: '../apps/gmm-distance.html', + }, { + label: 'Model Explorer', + href: '../apps/model-explorer.html', + }, { + label: 'Hanging Wall Effects', + href: '../apps/hw-fw.html', + }, { + label: 'Geographic Deaggregation', + href: '../apps/geo-deagg.html', + }, { + label: 'Exceedance Explorer', + href: '../apps/exceedance-explorer.html', + }, { + label: 'Services', + href: '../apps/services.html', + } + ]; + + this.createDashboard(); + } + + /** + * @method createDashboard + * + * Create the panels for the dashboard + */ + createDashboard() { + let elD3 = d3.select('body') + .append('div') + .attr('id', 'container') + .append('div') + .attr('id', 'dash'); + + elD3.selectAll('div') + .data(this.webapps) + .enter() + .append('div') + .attr('class', 'col-sm-offset-1 col-sm-4 col-sm-offset-1') + .on('click', (d, i) => { window.location = d.href; }) + .append('div') + .attr('class', 'panel panel-default') + .append('div') + .attr('class', 'panel-heading') + .append('h2') + .attr('class', 'panel-title') + .text((d, i) => {return d.label;}); + } + +} diff --git a/webapp/apps/js/DynamicCompare.js b/webapp/apps/js/DynamicCompare.js new file mode 100644 index 000000000..5f4c72079 --- /dev/null +++ b/webapp/apps/js/DynamicCompare.js @@ -0,0 +1,1486 @@ + +import { D3LineData } from './d3/data/D3LineData.js'; +import { D3LineLegendOptions } from './d3/options/D3LineLegendOptions.js'; +import { D3LineOptions } from './d3/options/D3LineOptions.js'; +import { D3LinePlot } from './d3/D3LinePlot.js'; +import { D3LineSeriesData } from './d3/data/D3LineSeriesData.js'; +import { D3LineSubView } from './d3/view/D3LineSubView.js'; +import { D3LineSubViewOptions } from './d3/options/D3LineSubViewOptions.js'; +import { D3LineView } from './d3/view/D3LineView.js'; +import { D3LineViewOptions } from './d3/options/D3LineViewOptions.js'; +import { D3TextOptions } from './d3/options/D3TextOptions.js'; + +import { Hazard } from './lib/HazardNew.js'; +import { HazardServiceResponse } from './response/HazardServiceResponse.js'; + +import Constraints from './lib/Constraints.js'; +import LeafletTestSitePicker from './lib/LeafletTestSitePicker.js'; +import NshmpError from './error/NshmpError.js'; +import { Preconditions } from './error/Preconditions.js'; +import Tools from './lib/Tools.js'; + +/** + * @fileoverview Class for the dynamic compare webpage, dynamic-compare.html. + * + * This class contains two plot panels with the following plots: + * first panel: + * - Model comparison of ground motion Vs. annual frequency of exceedence + * - Percent difference between the models + * second panel: + * - Response spectrum of the models + * - Percent difference of the response spectrum + * + * The class first class out to the source model webservice, + * nshmp-haz-ws/source/models, to get the usage and build the + * following menus: + * - Model + * - Second Model + * - IMT + * - Vs30 + * + * The IMT and Vs30 menus are created by the common supported values + * between the two selected models. + * + * Bootstrap tooltips are created and updated for the latitude, longitude, + * and return period inputs. + * + * The inputs, latitude, longitude, and return period, are monitored. If + * a bad or out of range value is entered the update button will + * remain disabled. Once all inputs are correctly entered the update + * button or enter can be pressed to render the results. + * + * The return period allowable minimum and maximum values are updated + * based on the choosen models such that the response spectrum + * is defined for the entire bounds for both models. + * + * The results are rendered using the D3 package. + * + * @class DynamicCompare + * @author Brandon Clayton + */ +export class DynamicCompare extends Hazard { + + /** @param {!Config} config - The config file */ + constructor(config) { + let webApp = 'DynamicCompare'; + let webServiceUrl = '/nshmp-haz-ws/haz'; + super(webApp, webServiceUrl, config); + this.header.setTitle('Dynamic Compare'); + + this.options = { + defaultFirstModel: 'WUS_2014', + defaultSecondModel: 'WUS_2018', + defaultImt: 'PGA', + defaultReturnPeriod: 2475, + defaultVs30: 760, + }; + + /** @type {HTMLElement} */ + this.contentEl = document.querySelector('#content'); + + /** @type {HTMLElement} */ + this.firstModelEl = document.querySelector('#first-model'); + + /** @type {HTMLElement} */ + this.secondModelEl = document.querySelector('#second-model'); + + /** @type {HTMLElement} */ + this.modelsEl = document.querySelector('.model'); + + /** @type {HTMLElement} */ + this.testSitePickerBtnEl = document.querySelector('#test-site-picker'); + + /** @type {Object} */ + this.comparableModels = undefined; + + /* Get webservice usage */ + this.getUsage(); + + /** X-axis domain for spectra plots - @type {Array {}; + this.returnPeriodEventHandler = () => {}; + this.vs30EventHandler = () => {}; + + this.addEventListener(); + } + + /** Add event listeners */ + addEventListener() { + /* Check latitude values on change */ + $(this.latEl).on('input', (event) => { + this.onCoordinate(event); + }); + + /* Check longitude values on change */ + $(this.lonEl).on('input', (event) => { + this.onCoordinate(event); + }); + + /* Listen for input changes */ + this.footer.onInput(this.inputsEl, this.footerOptions); + + /* Update menus when first model changes */ + this.firstModelEl.addEventListener('change', () => { + this.onFirstModelChange(); + }); + + /** Update menus on second model change */ + this.secondModelEl.addEventListener('change', () => { + this.onModelChange(); + }); + + /** Check query on test site load */ + this.testSitePicker.on('testSiteLoad', (event) => { + this.checkQuery(); + }); + + /* Bring Leaflet map up when clicked */ + $(this.testSitePickerBtnEl).on('click', (event) => { + let model = Tools.stringToParameter( + this.parameters.models, + this.firstModelEl.value); + + this.testSitePicker.plotMap(model.region); + }); + } + + /** + * Add an input tooltip for latitude, longitude, and return period + * using Constraints.addTooltip. + */ + addInputTooltip() { + let model = Tools.stringToParameter( + this.parameters.models, + this.firstModelEl.value); + + let region = this.parameters.region.values.find((region) => { + return region.value == model.region; + }); + + Constraints.addTooltip( + this.latEl, + region.minlatitude, + region.maxlatitude); + + Constraints.addTooltip( + this.lonEl, + region.minlongitude, + region.maxlongitude); + + let periodValues = this.parameters.returnPeriod.values; + Constraints.addTooltip( + this.returnPeriodEl, + periodValues.minimum, + periodValues.maximum); + } + + /** + * Add text with the ground motion difference. + * + * @param {Array} hazardResponses The hazard responses + * @param {D3LineSubView} subView The sub view + * @returns {SVGElement} The text element + */ + addGroundMotionDifferenceText(hazardResponses, subView) { + Preconditions.checkArgumentArrayInstanceOf( + hazardResponses, + HazardServiceResponse); + + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + let timeHorizon = this.returnPeriodEl.value; + let returnPeriod = 1 / timeHorizon; + let percentDifference = this.getGroundMotionDifference(hazardResponses); + + let xMax = this.hazardLinePlot.getXDomain(subView)[1]; + let text = `${timeHorizon} years, % diff = ${percentDifference}`; + + let textOptions = D3TextOptions.builder() + .dy(10) + .fontSize(18) + .textAnchor('end') + .build(); + + let textEl = this.hazardLinePlot.addText( + subView, + xMax, + returnPeriod, + text, + textOptions); + + return textEl; + } + + /** + * Process usage response from nshmp-haz-ws/source/models and set menus. + */ + buildInputs() { + this.spinner.off(); + this.setComparableModels(); + + this.setFirstModelMenu(); + this.setSecondModelMenu(); + this.secondModelEl.value = this.options.defaultSecondModel; + this.setParameterMenu(this.imtEl, this.options.defaultImt); + this.setParameterMenu(this.vs30El, this.options.defaultVs30); + this.setDefaultReturnPeriod(); + this.addInputTooltip(); + + $(this.controlPanelEl).removeClass('hidden'); + + } + + /** + * Check the current hash part of the URL for parameters, if they + * exist plot the results. + */ + checkQuery() { + let url = window.location.hash.substring(1); + let urlObject = Tools.urlQueryStringToObject(url); + + /* Make sure all pramameters are present in URL */ + if (!urlObject.hasOwnProperty('model') || + !urlObject.hasOwnProperty('latitude') || + !urlObject.hasOwnProperty('longitude') || + !urlObject.hasOwnProperty('imt') || + !urlObject.hasOwnProperty('returnperiod') || + !urlObject.hasOwnProperty('vs30')) return false; + + /* Update values for the menus */ + this.firstModelEl.value = urlObject.model[0]; + $(this.firstModelEl).trigger('change'); + this.secondModelEl.value = urlObject.model[1]; + this.latEl.value = urlObject.latitude; + this.lonEl.value = urlObject.longitude; + this.imtEl.value = urlObject.imt; + this.vs30El.value = urlObject.vs30; + this.returnPeriodEl.value = urlObject.returnperiod; + + /* Trigger events to update tooltips */ + $(this.latEl).trigger('input'); + $(this.lonEl).trigger('input'); + this.onReturnPeriodInput(); + this.addInputTooltip(); + + /* Get and plot results */ + $(this.inputsEl).trigger('change'); + let keypress = jQuery.Event('keypress'); + keypress.which = 13; + keypress.keyCode = 13; + $(document).trigger(keypress); + } + + /** + * Clear all plots + */ + clearPlots() { + this.hazardComponentLinePlot.clearAll(); + this.hazardComponentView.setTitle(this.hazardComponentPlotTitle); + + this.hazardLinePlot.clearAll(); + this.hazardView.setTitle(this.hazardPlotTitle); + this.hazardLinePlot.plotZeroRefLine(this.hazardView.lowerSubView); + + this.spectraComponentLinePlot.clearAll(); + this.spectraComponentView.setTitle(this.spectraComponentPlotTitle); + + this.spectraLinePlot.clearAll(); + this.spectraView.setTitle(this.spectraPlotTitle); + this.spectraLinePlot.plotZeroRefLine(this.spectraView.lowerSubView); + } + + /** + * Calculate the ground motion difference. + * + * @param {Array} hazardResponses The responses + */ + getGroundMotionDifference(hazardResponses) { + Preconditions.checkArgumentArrayInstanceOf( + hazardResponses, + HazardServiceResponse); + + let spectrum = []; + let imt = this.imtEl.value; + + let timeHorizon = this.returnPeriodEl.value; + let returnPeriod = 1 / timeHorizon; + + for (let hazardResponse of hazardResponses) { + let response = hazardResponse.getResponse(imt); + spectrum.push(response.calculateResponseSpectrum('Total', returnPeriod)); + } + + return Tools.percentDifference(spectrum[0], spectrum[1]); + } + + /** + * Return the Y limit for the hazard curves. + * + * @param {Array} hazardResponses The hazard responses + * @param {String} imt The IMT + * @returns {Array} The Y limit + */ + getHazardCurvesYLimit(hazardResponses, imt) { + Preconditions.checkArgumentArrayInstanceOf( + hazardResponses, + HazardServiceResponse); + + Preconditions.checkArgumentString(imt); + + let yValues = []; + + for (let hazardResponse of hazardResponses) { + let response = hazardResponse.getResponse(imt); + + for (let data of response.data) { + yValues.push(data.yValues); + } + } + + yValues = d3.merge(yValues).filter((y) => { return y > this.Y_MIN_CUTOFF; }); + + let min = d3.min(yValues); + let max = d3.max(yValues); + + return [ min, max ]; + } + + /** + * Get the metadata, associated with the hazard plots, + * about the selected parameters in the control panel. + * + * @return {Map>} The metadata Map + */ + getMetadataHazard() { + let models = [ + $(':selected', this.firstModelEl).text(), + $(':selected', this.secondModelEl).text(), + ]; + + let metadata = new Map(); + metadata.set('Model:', models) + metadata.set('Latitude:', [this.latEl.value]); + metadata.set('Longitude:', [this.lonEl.value]); + metadata.set('Intensity Measure Type:', [$(':selected', this.imtEl).text()]); + metadata.set('Vs30:', [$(':selected', this.vs30El).text()]); + + return metadata; + } + + /** + * Get the metadata, associated with the response spectra plots, + * about the selected parameters in the control panel. + * + * @return {Map>} The metadata Map + */ + getMetadataSpectra() { + let models = [ + $(':selected', this.firstModelEl).text(), + $(':selected', this.secondModelEl).text(), + ]; + + let metadata = new Map(); + metadata.set('Model:', models) + metadata.set('Latitude:', [this.latEl.value]); + metadata.set('Longitude:', [this.lonEl.value]); + metadata.set('Vs30:', [$(':selected', this.vs30El).text()]); + metadata.set('Return Period (years):', [this.returnPeriodEl.value]); + + return metadata; + } + + /** + * Calculate the percent difference of the models. + * + * @param {D3LineSubView} subView The sub view for the line data + * @param {D3LineData} lineData The hazard line data + * @param {Array} xLimit The X limit for the data + * @returns {D3LineData} The percent difference line data + */ + getModelDifferenceData(subView, lineData, xLimit) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentArrayOf(xLimit, 'number'); + + let firstModelSeries = lineData.series.find((series) => { + return series.lineOptions.id == this.firstModelEl.value; + }); + + let secondModelSeries = lineData.series.find((series) => { + return series.lineOptions.id == this.secondModelEl.value; + }); + + let firstModelData = D3LineSeriesData.intersectionX( + firstModelSeries, + secondModelSeries); + + let secondModelData = D3LineSeriesData.intersectionX( + secondModelSeries, + firstModelSeries); + + let firstModelYValues = firstModelData.map((xyPair) => { + return xyPair.y; + }); + + let secondModelYValues = secondModelData.map((xyPair) => { + return xyPair.y; + }); + + let xValues = firstModelData.map((xyPair) => { + return xyPair.x; + }); + + let yValues = Tools.percentDifferenceArray( + firstModelYValues, + secondModelYValues); + + let maxVal = d3.max(yValues, (/** @type {Number} */ y) => { + return Math.abs(y); + }); + + let yLimit = [-maxVal, maxVal]; + + let selectedFirstModel = $(':selected', this.firstModelEl).text(); + let selectedSecondModel = $(':selected', this.secondModelEl).text(); + + let label = selectedFirstModel + ' Vs. ' + selectedSecondModel; + + let lineOptions = D3LineOptions.builder() + .label(label) + .id('plot-difference') + .build(); + + let diffData = D3LineData.builder() + .subView(subView) + .data(xValues, yValues, lineOptions) + .xLimit(xLimit) + .yLimit(yLimit) + .build(); + + return diffData; + } + + /** + * Return the Y limit for the response spectrum. + * + * @param {Array} hazardResponses The hazard responses + * @returns {Array} The Y limit + */ + getResponseSpectraYLimit(hazardResponses) { + Preconditions.checkArgumentArrayInstanceOf( + hazardResponses, + HazardServiceResponse); + + let minReturnPeriod = 1 / this.parameters.returnPeriod.values.maximum; + let maxReturnPeriod = 1 / this.parameters.returnPeriod.values.minimum; + + let spectras = []; + + for (let hazardResponse of hazardResponses) { + let spectraMin = hazardResponse.toResponseSpectrum(minReturnPeriod); + let spectraMax = hazardResponse.toResponseSpectrum(maxReturnPeriod); + + spectras.push(spectraMin); + spectras.push(spectraMax); + } + + let yValues = []; + + for (let spectra of spectras) { + for (let data of spectra.data) { + yValues.push(data.yValues); + } + } + + yValues = d3.merge(yValues).filter((y) => { return y > this.Y_MIN_CUTOFF; }); + + let maxSpectraGm = d3.max(yValues); + let minSpectraGm = d3.min(yValues); + + maxSpectraGm = isNaN(maxSpectraGm) ? 1.0 : maxSpectraGm; + minSpectraGm = isNaN(minSpectraGm) ? 1e-4 : minSpectraGm; + + return [minSpectraGm, maxSpectraGm]; + } + + /** + * On longitude or latitude input, check that the coordinate values + * input are good values. + * + * @param {Event} event - The event that triggered the input. + */ + onCoordinate(event) { + let model = Tools.stringToParameter( + this.parameters.models, + this.firstModelEl.value); + + let region = Tools.stringToParameter(this.parameters.region, model.region); + + this.checkCoordinates(event.target, region); + } + + /** + * Update menus and update the second model + */ + onFirstModelChange() { + this.setSecondModelMenu(); + this.onModelChange(); + this.latEl.value = null; + this.lonEl.value = null; + } + + /** + * Handler to update the hazard plots on IMT change. + * + * @param {String} firstModel The first model value + * @param {String} secondModel The second model value + * @param {Array} hazardResponses The hazard responses + */ + onIMTChange(firstModel, secondModel, hazardResponses) { + Preconditions.checkArgumentString(firstModel); + Preconditions.checkArgumentString(secondModel); + Preconditions.checkArgumentArrayInstanceOf( + hazardResponses, + HazardServiceResponse); + + this.serializeUrls(); + + if (firstModel != this.firstModelEl.value || + secondModel != this.secondModelEl.value) { + return; + } + + this.plotHazardCurves(hazardResponses); + this.plotHazardComponentCurves(hazardResponses); + } + + /** + * Update menus on model change + */ + onModelChange() { + this.clearPlots(); + this.setParameterMenu(this.imtEl, this.options.defaultImt); + this.setParameterMenu(this.vs30El, this.options.defaultVs30); + this.addInputTooltip(); + } + + /** + * Handler to update the plots when the return period is changed. + * + * @param {String} firstModel The first model value + * @param {String} secondModel The second model value + * @param {Array} hazardResponses The hazard responses + */ + onReturnPeriodChange(firstModel, secondModel, hazardResponses) { + Preconditions.checkArgumentString(firstModel); + Preconditions.checkArgumentString(secondModel); + Preconditions.checkArgumentArrayInstanceOf( + hazardResponses, + HazardServiceResponse); + + if (firstModel != this.firstModelEl.value || + secondModel != this.secondModelEl.value) { + return; + } + + this.serializeUrls(); + this.plotResponseSpectrum(hazardResponses); + this.plotResponseSpectrumComponents(hazardResponses); + + this.plotHazardCurves(hazardResponses); + this.plotHazardComponentCurves(hazardResponses); + } + + /** + * Handler for the return period line being dragged on the hazard curve plot. + * + * @param {Array} hazardResponses The hazard responses + * @param {D3LineSubView} subView The sub view + * @param {Number} returnPeriod The return period + * @param {SVGElement} textEl The return period difference text element + */ + onReturnPeriodDrag(hazardResponses, subView, textEl, returnPeriod) { + Preconditions.checkArgumentArrayInstanceOf( + hazardResponses, + HazardServiceResponse); + + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentInstanceOfSVGElement(textEl); + Preconditions.checkArgumentNumber(returnPeriod); + + let timeHorizon = 1 / returnPeriod; + timeHorizon = Number(timeHorizon.toFixed(2)); + this.returnPeriodEl.value = timeHorizon; + + this.checkReturnPeriodButtons(); + + this.plotResponseSpectrum(hazardResponses); + this.plotResponseSpectrumComponents(hazardResponses); + this.updateGroundMotionDifferenceText(hazardResponses, subView, textEl); + + this.serializeUrls(); + } + + /** + * Handler to update the plots on vs30 change. + * + * @param {String} firstModel The first model value + * @param {String} secondModel The second model value + * @param {Array} hazardResponses The hazard responses + * @param {Number} vs30Value The vs30 + */ + onVs30Change(firstModel, secondModel, hazardResponses, vs30) { + Preconditions.checkArgumentString(firstModel); + Preconditions.checkArgumentString(secondModel); + Preconditions.checkArgumentString(vs30); + Preconditions.checkArgumentArrayInstanceOf( + hazardResponses, + HazardServiceResponse); + + if (vs30 != this.vs30El.value || + firstModel != this.firstModelEl.value || + secondModel != this.secondModelEl.value) { + this.clearPlots(); + } else { + this.serializeUrls(); + this.plotResponseSpectrum(hazardResponses); + this.plotResponseSpectrumComponents(hazardResponses); + + this.plotHazardCurves(hazardResponses); + this.plotHazardComponentCurves(hazardResponses); + } + } + + /** + * Plot the hazard comonent curves. + * + * @param {Array} hazardResponses The hazard responses + */ + plotHazardComponentCurves(hazardResponses) { + Preconditions.checkArgumentArrayInstanceOf( + hazardResponses, + HazardServiceResponse); + + let subView = this.hazardComponentView.upperSubView; + + this.hazardComponentLinePlot.clear(subView); + + let yLimit = this.getHazardCurvesYLimit(hazardResponses, this.imtEl.value); + + let lineData = []; + let lineStyles = [ '-', '--' ]; + + for (let index in hazardResponses) { + let hazardResponse = hazardResponses[index]; + + let lineStyle = lineStyles[index]; + + let dataBuilder = D3LineData.builder() + .subView(subView) + .removeSmallValues(this.Y_MIN_CUTOFF) + .yLimit(yLimit); + + let response = hazardResponse.getResponse(this.imtEl.value); + let xValues = response.metadata.xValues; + + let model = response.metadata.model; + + for (let componentData of response.getDataComponents()) { + let yValues = componentData.yValues; + + let lineOptions = D3LineOptions.builder() + .color(Tools.hazardComponentToColor(componentData.component)) + .id(`${model.value}-${componentData.component}`) + .label(`${model.year} ${model.region}: ${componentData.component}`) + .lineStyle(lineStyle) + .build(); + + dataBuilder.data(xValues, yValues, lineOptions); + } + + lineData.push(dataBuilder.build()); + } + + let hazardComponentData = D3LineData.of(...lineData); + + this.hazardComponentLinePlot.plot(hazardComponentData); + + let imt = $(':selected', this.imtEl).text(); + let vs30 = $(':selected', this.vs30El).text(); + + let model = Tools.stringToParameter( + this.parameters.models, + this.firstModelEl.value); + + let siteTitle = this.testSitePicker.getTestSiteTitle(model.region); + let title = `Hazard Component Curves: ${siteTitle}, ${imt}, ${vs30}`; + + this.hazardComponentView.setTitle(title); + + let metadata = this.getMetadataHazard(); + this.hazardComponentView.setMetadata(metadata); + this.hazardComponentView.createMetadataTable(); + + this.hazardComponentView.setSaveData(hazardComponentData); + this.hazardComponentView.createDataTable(hazardComponentData); + } + + /** + * Plot the total hazard curve. + * + * @param {Array} hazardResponses The hazard responses + */ + plotHazardCurves(hazardResponses) { + Preconditions.checkArgumentArrayInstanceOf( + hazardResponses, + HazardServiceResponse); + + let subView = this.hazardView.upperSubView; + + this.hazardLinePlot.clear(subView); + + let metadata = this.getMetadataHazard(); + + let yLimit = this.getHazardCurvesYLimit(hazardResponses, this.imtEl.value); + + let dataBuilder = D3LineData.builder() + .subView(subView) + .removeSmallValues(this.Y_MIN_CUTOFF) + .yLimit(yLimit); + + for (let hazardResponse of hazardResponses) { + let response = hazardResponse.getResponse(this.imtEl.value); + let totalHazardData = response.getDataComponent('Total'); + + let xValues = response.metadata.xValues; + let yValues = totalHazardData.yValues; + + let model = response.metadata.model; + + let lineOptions = D3LineOptions.builder() + .id(model.value) + .label(model.display) + .build(); + + dataBuilder.data(xValues, yValues, lineOptions); + } + + let hazardData = dataBuilder.build(); + + this.hazardLinePlot.plot(hazardData); + + let timeHorizon = this.returnPeriodEl.value; + let returnPeriod = 1 / timeHorizon; + + let returnPeriodPlotEl = this.hazardLinePlot.plotHorizontalRefLine( + subView, + returnPeriod); + + let textEl = this.addGroundMotionDifferenceText(hazardResponses, subView); + + let returnPeriodValues = this.parameters.returnPeriod.values; + + let yLimitDrag = [ + 1 / returnPeriodValues.maximum, + 1 / returnPeriodValues.minimum ]; + + this.hazardLinePlot.makeDraggableInY( + subView, + returnPeriodPlotEl, + yLimitDrag, + (returnPeriod) => { + this.onReturnPeriodDrag( + hazardResponses, + subView, + textEl, + returnPeriod); + }); + + let imt = $(':selected', this.imtEl).text(); + let vs30 = $(':selected', this.vs30El).text(); + + let model = Tools.stringToParameter( + this.parameters.models, + this.firstModelEl.value); + + let siteTitle = this.testSitePicker.getTestSiteTitle(model.region); + let title = `Hazard Curves: ${siteTitle}, ${imt}, ${vs30}`; + + this.hazardView.setTitle(title); + + let hazardDiffData = this.plotHazardCurveDifference(hazardData); + + this.hazardView.setMetadata(metadata); + this.hazardView.createMetadataTable(); + + this.hazardView.setSaveData(hazardData, hazardDiffData); + this.hazardView.createDataTable(hazardData, hazardDiffData); + } + + /** + * Plot the total hazard curve difference. + * + * @param {D3LineData} hazardData The hazard data + */ + plotHazardCurveDifference(hazardData) { + let subView = this.hazardView.lowerSubView; + + this.hazardLinePlot.clear(subView); + + let xLimit = hazardData.getXLimit(); + + let hazardDiffData = this.getModelDifferenceData(subView, hazardData, xLimit); + + this.hazardLinePlot.plot(hazardDiffData); + this.hazardLinePlot.plotZeroRefLine(subView); + + return hazardDiffData; + } + + /** + * Plot the response spectrum calculated from the total hazard component. + * + * @param {Array} hazardResponses The hazard responses + */ + plotResponseSpectrum(hazardResponses) { + Preconditions.checkArgumentArrayInstanceOf( + hazardResponses, + HazardServiceResponse); + + let subView = this.spectraView.upperSubView; + + this.spectraLinePlot.clear(subView); + + let returnPeriod = 1 / this.returnPeriodEl.value; + + let dataBuilder = D3LineData.builder() + .subView(subView); + + let pgaBuilder = D3LineData.builder() + .subView(subView); + + for (let hazardResponse of hazardResponses) { + let spectra = hazardResponse.calculateResponseSpectrum('Total', returnPeriod); + let xValues = spectra[0]; + let yValues = spectra[1]; + + let model = hazardResponse.response[0].metadata.model; + + let iPGA = xValues.indexOf(Tools.imtToValue('PGA')); + let pgaX = xValues.splice(iPGA, 1); + let pgaY = yValues.splice(iPGA, 1); + + let pgaOptions = D3LineOptions.builder() + .id(model.value) + .label(model.display) + .lineStyle('none') + .markerStyle('s') + .showInLegend(false) + .build(); + + pgaBuilder.data(pgaX, pgaY, pgaOptions, [ 'PGA' ]); + + let lineOptions = D3LineOptions.builder() + .id(model.value) + .label(model.display) + .build(); + + dataBuilder.data(xValues, yValues, lineOptions); + } + + let xLimit = this.spectraXDomain; + let yLimit = this.getResponseSpectraYLimit(hazardResponses); + + let spectraLineData = dataBuilder + .xLimit(xLimit) + .yLimit(yLimit) + .build(); + + let spectraPGAData = pgaBuilder + .xLimit(xLimit) + .yLimit(yLimit) + .build(); + + this.spectraLinePlot.plot(spectraPGAData); + this.spectraLinePlot.plot(spectraLineData); + + let model = Tools.stringToParameter( + this.parameters.models, + this.firstModelEl.value); + + let vs30 = $(':selected', this.vs30El).text(); + let siteTitle = this.testSitePicker.getTestSiteTitle(model.region); + + let title = `Response Spectrum at ${this.returnPeriodEl.value}` + + ` years, ${siteTitle}, ${vs30}`; + + this.spectraView.setTitle(title); + + let metadata = this.getMetadataSpectra(); + this.spectraView.setMetadata(metadata); + this.spectraView.createMetadataTable(); + + let spectraDiffData = this.plotResponseSpectrumDifference(spectraLineData); + + let spectraData = spectraPGAData.concat(spectraLineData); + this.spectraView.createDataTable(spectraData, spectraDiffData); + this.spectraView.setSaveData(spectraData, spectraDiffData); + } + + /** + * Plot the response spectrum component curves. + * + * @param {Array} hazardResponses The hazard responses + */ + plotResponseSpectrumComponents(hazardResponses) { + Preconditions.checkArgumentArrayInstanceOf( + hazardResponses, + HazardServiceResponse); + + let subView = this.spectraComponentView.upperSubView; + + this.spectraComponentLinePlot.clear(subView); + + let returnPeriod = 1 / this.returnPeriodEl.value; + + let lineStyles = [ '-', '--' ]; + let markerStyles = [ 's', '*' ] + let lineData = []; + let pgaData = []; + + let xLimit = this.spectraXDomain; + let yLimit = this.getResponseSpectraYLimit(hazardResponses); + + for (let index in hazardResponses) { + let hazardResponse = hazardResponses[index]; + + let lineStyle = lineStyles[index]; + let markerStyle = markerStyles[index]; + + let spectra = hazardResponse.toResponseSpectrum(returnPeriod); + + let dataBuilder = D3LineData.builder() + .subView(subView) + .xLimit(xLimit) + .yLimit(yLimit); + + let pgaBuilder = D3LineData.builder() + .subView(subView) + .xLimit(xLimit) + .yLimit(yLimit); + + let model = hazardResponse.response[0].metadata.model; + + for (let componentData of spectra.getDataComponents()) { + let xValues = componentData.xValues; + let yValues = componentData.yValues; + + let iPGA = xValues.indexOf(Tools.imtToValue('PGA')); + let pgaX = xValues.splice(iPGA, 1); + let pgaY = yValues.splice(iPGA, 1); + + let pgaOptions = D3LineOptions.builder() + .color(Tools.hazardComponentToColor(componentData.component)) + .id(`${model.value}-${componentData.component}`) + .label(`${model.year} ${model.region}: ${componentData.component}`) + .lineStyle('none') + .markerStyle(markerStyle) + .showInLegend(false) + .build(); + + pgaBuilder.data(pgaX, pgaY, pgaOptions, [ 'PGA' ]); + + let lineOptions = D3LineOptions.builder() + .color(Tools.hazardComponentToColor(componentData.component)) + .id(`${model.value}-${componentData.component}`) + .label(`${model.year} ${model.region}: ${componentData.component}`) + .lineStyle(lineStyle) + .build(); + + dataBuilder.data(xValues, yValues, lineOptions); + } + + lineData.push(dataBuilder.build()); + pgaData.push(pgaBuilder.build()); + } + + let spectraComponentLineData = D3LineData.of(...lineData); + let spectraComponentPGAData = D3LineData.of(...pgaData); + + this.spectraComponentLinePlot.plot(spectraComponentPGAData); + this.spectraComponentLinePlot.plot(spectraComponentLineData); + + let model = Tools.stringToParameter( + this.parameters.models, + this.firstModelEl.value); + + let vs30 = $(':selected', this.vs30El).text(); + let siteTitle = this.testSitePicker.getTestSiteTitle(model.region); + + let title = `Response Spectrum at ${this.returnPeriodEl.value}` + + ` years, ${siteTitle}, ${vs30}`; + + this.spectraComponentView.setTitle(title); + + let metadata = this.getMetadataSpectra(); + this.spectraComponentView.setMetadata(metadata); + this.spectraComponentView.createMetadataTable(); + + let spectraData = spectraComponentPGAData.concat(spectraComponentLineData); + this.spectraComponentView.createDataTable(spectraData); + this.spectraComponentView.setSaveData(spectraData); + } + + /** + * Plot the response spectrum percent difference. + * + * @param {D3LineData} spectraData The spectra data + */ + plotResponseSpectrumDifference(spectraData) { + let subView = this.spectraView.lowerSubView; + + this.spectraLinePlot.clear(subView); + + let spectraDiffData = this.getModelDifferenceData( + subView, + spectraData, + this.spectraXDomain); + + this.spectraLinePlot.plot(spectraDiffData); + this.spectraLinePlot.plotZeroRefLine(subView); + + return spectraDiffData; + } + + /** + * @override + * Get URLs to query. + */ + serializeUrls() { + let urls = []; + let inputs = $(this.inputsEl).serialize(); + let windowUrl = ''; + + for (let modelEl of [this.firstModelEl, this.secondModelEl]) { + let model = Tools.stringToParameter( + this.parameters.models, + modelEl.value); + + urls.push(`${this.dynamicUrl}?model=${model.value}&${inputs}`); + + windowUrl += `&model=${model.value}`; + } + + windowUrl += `&${inputs}&imt=${this.imtEl.value}` + + `&returnperiod=${this.returnPeriodEl.value}`; + + window.location.hash = windowUrl.substring(1); + return urls; + } + + /** + * Given the models in nshmp-haz-ws/source/models find only models + * that can be compared, ones that have that same region. + */ + setComparableModels() { + this.comparableModels = this.parameters.models.values.filter((model) => { + let regions = this.parameters.models.values.filter((modelCheck) => { + return model.region == modelCheck.region; + }); + return regions.length > 1; + }); + } + + /** + * Set the first model select menu with only comparable models. + * See setComparableModels(). + */ + setFirstModelMenu() { + Tools.setSelectMenu(this.firstModelEl, this.comparableModels); + this.firstModelEl.value = this.options.defaultFirstModel; + } + + /** + * Set select menus with supported values of the selected models. + * + * @param {HTMLElement} el - The dom element of the select menu to set. + */ + setParameterMenu(el, defaultValue) { + let firstModel = Tools.stringToParameter( + this.parameters.models, + this.firstModelEl.value); + + let secondModel = Tools.stringToParameter( + this.parameters.models, + this.secondModelEl.value); + + let supports = []; + supports.push(firstModel.supports[el.id]); + supports.push(secondModel.supports[el.id]); + + let supportedValues = Tools.supportedParameters( + this.parameters[el.id ], + supports); + + Tools.setSelectMenu(el, supportedValues); + + let hasDefaultValue = supportedValues.some((val) => { + return defaultValue == val.value; + }); + + if (hasDefaultValue) el.value = defaultValue; + } + + /** + * Set the second model select menu with only comparable models to + * the first selected model. + */ + setSecondModelMenu() { + let selectedModel = Tools.stringToParameter( + this.parameters.models, + this.firstModelEl.value); + + let comparableModels = this.comparableModels.filter((model) => { + return selectedModel.region == model.region && + selectedModel != model; + }); + + Tools.setSelectMenu(this.secondModelEl, comparableModels); + } + + /** + * Build the view for the hazard component curves plot + * + * @returns {D3LineView} The spectra line view + */ + setupHazardComponentView() { + /* Upper sub view options: hazard plot */ + let upperSubViewOptions = D3LineSubViewOptions.upperBuilder() + .dragLineSnapTo(1e-10) + .filename('dynamic-compare-hazard-components') + .label('Hazard Component Curves') + .lineLabel('Model') + .xLabel('Ground Motion (g)') + .xAxisScale('log') + .yAxisScale('log') + .yLabel('Annual Frequency of Exceedence') + .yValueToExponent(true) + .build(); + + /* Plot view options */ + let viewOptions = D3LineViewOptions.builder() + .titleFontSize(14) + .viewSize('min') + .build(); + + /* Build the view */ + let view = D3LineView.builder() + .containerEl(this.contentEl) + .viewOptions(viewOptions) + .upperSubViewOptions(upperSubViewOptions) + .build(); + + view.setTitle(this.hazardComponentPlotTitle); + + return view; + } + + /** + * Build the view for the hazard curve plot with % difference + * + * @returns {D3LineView} The spectra line view + */ + setupHazardView() { + /* Lower sub view options: % difference plot */ + let lowerSubViewOptions = D3LineSubViewOptions.lowerBuilder() + .defaultYLimit([ -1.0, 1.0 ]) + .filename('dynamic-compare-hazard-difference') + .label('Hazard Curves Percent Difference') + .lineLabel('Model') + .showLegend(false) + .xLabel('Ground Motion (g)') + .yLabel('% Difference') + .build(); + + /* Upper sub view legend options: hazard plot */ + let upperLegendOptions = D3LineLegendOptions.upperBuilder() + .location('bottom-left') + .build(); + + /* Upper sub view options: hazard plot */ + let upperSubViewOptions = D3LineSubViewOptions.upperBuilder() + .dragLineSnapTo(1e-8) + .filename('dynamic-compare-hazard') + .label('Hazard Curves') + .lineLabel('Model') + .legendOptions(upperLegendOptions) + .xLabel('Ground Motion (g)') + .yAxisScale('log') + .yLabel('Annual Frequency of Exceedence') + .yValueToExponent(true) + .build(); + + /* Plot view options */ + let viewOptions = D3LineViewOptions.builder() + .syncXAxisScale(true, 'log') + .syncYAxisScale(false) + .titleFontSize(14) + .viewSize('min') + .build(); + + /* Build the view */ + let view = D3LineView.builder() + .addLowerSubView(true) + .containerEl(this.contentEl) + .viewOptions(viewOptions) + .lowerSubViewOptions(lowerSubViewOptions) + .upperSubViewOptions(upperSubViewOptions) + .build(); + + view.setTitle(this.hazardPlotTitle); + + return view; + } + + /** + * Build the view for the spectra plot with % difference + * + * @returns {D3LineView} The spectra line view + */ + setupSpectraComponentView() { + /* Upper sub view options: spectra plot */ + let upperSubViewOptions = D3LineSubViewOptions.upperBuilder() + .defaultXLimit(this.spectraXDomain) + .filename('dynamic-compare-spectra-components') + .label('Response Spectrum Component Curves') + .lineLabel('Model') + .xAxisScale('log') + .yAxisScale('log') + .xLabel('Spectral Period (s)') + .yLabel('Ground Motion (g)') + .build(); + + /* Plot view options */ + let viewOptions = D3LineViewOptions.builder() + .titleFontSize(14) + .viewSize('min') + .build(); + + /* Build the view */ + let view = D3LineView.builder() + .containerEl(this.contentEl) + .viewOptions(viewOptions) + .upperSubViewOptions(upperSubViewOptions) + .build(); + + view.setTitle(this.spectraComponentPlotTitle); + + return view; + } + + /** + * Build the view for the spectra plot with % difference + * + * @returns {D3LineView} The spectra line view + */ + setupSpectraView() { + /* Lower sub view options: % difference plot */ + let lowerSubViewOptions = D3LineSubViewOptions.lowerBuilder() + .defaultXLimit(this.spectraXDomain) + .defaultYLimit([ -1.0, 1.0 ]) + .filename('dynamic-compare-spectra-difference') + .label('Response Spectrum Percent Difference') + .lineLabel('Model') + .showLegend(false) + .xLabel('Period (s)') + .yLabel('% Difference') + .build(); + + /* Upper sub view legend options: hazard plot */ + let upperLegendOptions = D3LineLegendOptions.upperBuilder() + .location('bottom-left') + .build(); + + /* Upper sub view options: spectra plot */ + let upperSubViewOptions = D3LineSubViewOptions.upperBuilder() + .defaultXLimit(this.spectraXDomain) + .filename('dynamic-compare-spectra') + .label('Response Spectrum') + .legendOptions(upperLegendOptions) + .lineLabel('Model') + .xLabel('Spectral Period (s)') + .yAxisScale('log') + .yLabel('Ground Motion (g)') + .build(); + + /* Plot view options */ + let viewOptions = D3LineViewOptions.builder() + .syncXAxisScale(true, 'log') + .syncYAxisScale(false) + .titleFontSize(14) + .viewSize('min') + .build(); + + /* Build the view */ + let view = D3LineView.builder() + .addLowerSubView(true) + .containerEl(this.contentEl) + .viewOptions(viewOptions) + .lowerSubViewOptions(lowerSubViewOptions) + .upperSubViewOptions(upperSubViewOptions) + .build(); + + view.setTitle(this.spectraPlotTitle); + + return view; + } + + /** + * Update the ground motion difference text. + * + * @param {Array} hazardResponses The responses + * @param {D3LineSubView} subView The sub view + * @param {SVGElement} textEl The SVG text element to update + */ + updateGroundMotionDifferenceText(hazardResponses, subView, textEl) { + Preconditions.checkArgumentArrayInstanceOf( + hazardResponses, + HazardServiceResponse); + + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentInstanceOfSVGElement(textEl); + + let timeHorizon = this.returnPeriodEl.value; + let returnPeriod = 1 / timeHorizon; + let percentDifference = this.getGroundMotionDifference(hazardResponses); + let text = `${timeHorizon} years, % diff = ${percentDifference}`; + + let xMax = this.hazardLinePlot.getXDomain(subView)[1]; + this.hazardLinePlot.moveText(subView, xMax, returnPeriod, textEl); + this.hazardLinePlot.updateText(textEl, text); + } + + /** + * Call the hazard web service for each model and plot the resuls. + */ + updatePlot() { + let urls = this.serializeUrls(); + + let jsonCall = Tools.getJSONs(urls); + this.spinner.on(jsonCall.reject, 'Calculating'); + + Promise.all(jsonCall.promises).then((results) => { + this.spinner.off(); + + Tools.checkResponses(results); + + let hazardResponses = results.map((result) => { + return new HazardServiceResponse(result); + }); + + /* Set footer metadata */ + this.footer.setWebServiceMetadata(hazardResponses[0]); + + /* Update tooltips for input */ + this.addInputTooltip(); + + /* Plot response spectrum */ + this.plotResponseSpectrum(hazardResponses); + + /* Plot hazard curves */ + this.plotHazardCurves(hazardResponses); + + /* Plot spectra component curves */ + this.plotResponseSpectrumComponents(hazardResponses); + + /* Plot hazard component curves */ + this.plotHazardComponentCurves(hazardResponses); + + let firstModel = this.firstModelEl.value; + let secondModel = this.secondModelEl.value; + let vs30 = this.vs30El.value; + + this.updatePlotIMT(firstModel, secondModel, hazardResponses); + this.updatePlotReturnPeriod(firstModel, secondModel, hazardResponses); + this.updatePlotVs30(firstModel, secondModel, hazardResponses, vs30); + + /* Get raw data */ + this.footer.onRawDataBtn(urls); + }).catch((errorMessage) => { + this.spinner.off(); + NshmpError.throwError(errorMessage); + }); + } + + /** Update IMT event handler */ + updatePlotIMT(firstModel, secondModel, hazardResponses) { + let imtHandler = () => { + this.onIMTChange(firstModel, secondModel, hazardResponses) + }; + + this.imtEl.removeEventListener('change', this.imtHandler); + this.imtEl.addEventListener('change', imtHandler); + this.imtHandler = imtHandler; + } + + /** Update return period event handler */ + updatePlotReturnPeriod(firstModel, secondModel, hazardResponses) { + let returnPeriodEventHandler = () => { + this.onReturnPeriodChange(firstModel, secondModel, hazardResponses); + }; + + this.returnPeriodEl.removeEventListener( + 'returnPeriodChange', + this.returnPeriodEventHandler) + + this.returnPeriodEl.addEventListener( + 'returnPeriodChange', + returnPeriodEventHandler); + + this.returnPeriodEventHandler = returnPeriodEventHandler; + } + + /** Update vs30 event handler */ + updatePlotVs30(firstModel, secondModel, hazardResponses, vs30) { + let vs30EventHandler = () => { + this.onVs30Change(firstModel, secondModel, hazardResponses, vs30); + }; + + this.vs30El.removeEventListener('change', this.vs30EventHandler); + this.vs30El.addEventListener('change', vs30EventHandler); + this.vs30EventHandler = vs30EventHandler; + } + +} diff --git a/webapp/apps/js/ExceedanceExplorer.js b/webapp/apps/js/ExceedanceExplorer.js new file mode 100644 index 000000000..9258e046a --- /dev/null +++ b/webapp/apps/js/ExceedanceExplorer.js @@ -0,0 +1,416 @@ + +import Footer from './lib/Footer.js'; +import Header from './lib/Header.js'; +import Constraints from './lib/Constraints.js'; + +import { D3LineSubViewOptions } from './d3/options/D3LineSubViewOptions.js'; +import { D3LineView } from './d3/view/D3LineView.js'; +import { D3LinePlot } from './d3/D3LinePlot.js'; +import { D3XYPair } from './d3/data/D3XYPair.js'; +import { D3LineData } from './d3/data/D3LineData.js'; +import { D3LineOptions } from './d3/options/D3LineOptions.js'; +import { D3LineLegendOptions } from './d3/options/D3LineLegendOptions.js'; +import { Maths } from './calc/Maths.js'; +import { ExceedanceModel } from './calc/ExceedanceModel.js'; +import { Preconditions } from './error/Preconditions.js'; +import { UncertaintyModel } from './calc/UncertaintyModel.js'; + +/** + * @fileoverview Class for exceedance-explorer.html + * + * @class ExceedanceExplorer + */ +export class ExceedanceExplorer { + + constructor() { + /* Add footer */ + this.footer = new Footer() + .removeButtons() + .removeInfoIcon(); + + /* Add header */ + this.header = new Header(); + + /* Set HTML elements */ + this.formEl = document.querySelector('#inputs'); + this.controlPanelEl = document.querySelector('#control'); + this.medianEl = document.querySelector('#median'); + this.sigmaEl = document.querySelector('#sigma'); + this.truncationEl = document.querySelector('#truncation'); + this.truncationLevelEl = document.querySelector('#truncation-level'); + this.rateEl = document.querySelector('#rate'); + this.addPlotBtnEl = document.querySelector('#add-plot'); + this.clearPlotBtnEl = document.querySelector('#clear-plot'); + this.containerEl = document.querySelector('#content'); + this.removePlotBtnEl = document.querySelector('#remove-plot'); + + this.removePlotBtnEl.disabled = true; + this.clearPlotBtnEl.disabled = true; + + /* Default values */ + this.defaults = { + median: 1, + sigma: 0.5, + truncation: true, + truncationLevel: 3, + rate: 1, + xMin: 0.0001, + xMax: 10.0, + xPoints: 100 + }; + + const formEls = [ + this.medianEl, + this.rateEl, + this.sigmaEl, + this.truncationLevelEl + ]; + + this.addInputTooltips(formEls); + this.onInputCheck(formEls); + this.setDefaults(); + this.eventListeners(); + this.checkInputs(); + this.controlPanelEl.classList.remove('hidden'); + + this.plotView = this.createView(); + this.plotView.setTitle('Exceedance'); + + /* Create plot */ + this.plot = new D3LinePlot(this.plotView); + this.exceedanceData = this.plot.upperLineData; + + this.sequence = this.createSequence(); + this.plotSelection(); + + this.medianValues = []; + this.sigmaValues = []; + this.rateValues = []; + this.truncationValues = []; + this.truncationLevelValues = []; + } + + /** + * Add form input tooltips. + * + * @param {HTMLElement[]} formEls + */ + addInputTooltips(formEls) { + Preconditions.checkArgumentArrayInstanceOf(formEls, Element); + + for (let el of formEls) { + Constraints.addTooltip(el, el.getAttribute('min'), el.getAttribute('max')); + } + } + + /** + * Add a new plot + */ + addPlot() { + this.clearPlotBtnEl.disabled = false; + + let model = new UncertaintyModel( + this.mean(), + this.sigma(), + this.truncationLevel() === 'N/A' ? 0 : this.truncationLevel()); + + let sequence = []; + let label = `μ=${this.median()}, σ=${this.sigma()},` + + ` rate=${this.rate()}, n=${this.truncationLevel()}`; + + if (this.truncationEl.checked) { + sequence = ExceedanceModel.truncationUpperOnlySequence(model, this.sequence); + } else { + sequence = ExceedanceModel.truncationOffSequence(model, this.sequence); + } + + let xValues = sequence.map(xy => Math.exp(xy.x)); + let yValues = sequence.map(xy => Maths.round(xy.y * this.rate() , 4)); + + let dataBuilder = this.getDataBuilder(); + + let lineOptions = D3LineOptions.builder() + .label(label) + .markerSize(3) + .lineWidth(1.25) + .build(); + + let data = dataBuilder + .data(xValues, yValues, lineOptions) + .removeSmallValues(1e-14) + .build(); + + this.exceedanceData = data; + + this.removePlotBtnEl.disabled = true; + this.plot.clearAll(); + this.plot.plot(data); + + this.updateValues(); + this.setPlotData(data); + } + + /** + * Check for any form input errors + */ + checkInputs() { + const hasError = this.formEl.querySelectorAll('.has-error').length > 0; + this.addPlotBtnEl.disabled = hasError; + } + + /** + * Clear all plots + */ + clearPlot() { + this.plot.clearAll(); + this.removePlotBtnEl.disabled = true; + this.exceedanceData = D3LineData.builder() + .subView(this.exceedanceData.subView) + .build(); + + this.medianValues = []; + this.sigmaValues = []; + this.rateValues = []; + this.truncationValues = []; + this.truncationLevelValues = []; + + this.clearPlotBtnEl.disabled = true; + } + + /** + * Create the XY sequence to plot + */ + createSequence() { + const xMin = this.defaults.xMin; + const xMax = this.defaults.xMax; + const xPoints = this.defaults.xPoints + + return d3.ticks(Math.log(xMin), Math.log(xMax), xPoints).map((x) => { + return new D3XYPair(x, 0); + }); + } + + /** + * Create the D3LineView + */ + createView() { + const legendOptions = D3LineLegendOptions.upperBuilder() + .location('bottom-left') + .build(); + + const upperSubViewOptions = D3LineSubViewOptions.upperBuilder() + .filename('exceedance') + .label('Exceedance Models') + .legendOptions(legendOptions) + .lineLabel('Exceedance Model') + .xValueToExponent(true) + .xAxisScale('log') + .xLabel('Ground Motion (g)') + .yLabel('Annual Frequency of Exceedance') + .build(); + + return D3LineView.builder() + .containerEl(this.containerEl) + .upperSubViewOptions(upperSubViewOptions) + .build(); + } + + /** + * Add event listeners + */ + eventListeners() { + this.truncationEl.addEventListener('change', () => { + this.truncationLevelEl.disabled = !this.truncationEl.checked; + }); + + this.formEl.addEventListener('change', () => { + this.checkInputs(); + }); + + this.formEl.addEventListener('input', () => { + this.checkInputs(); + }); + + this.addPlotBtnEl.addEventListener('click', () => { + this.addPlot(); + }); + + this.clearPlotBtnEl.addEventListener('click', () => { + this.clearPlot(); + }); + } + + /** + * Return a D3LineData.Builder for plotting of the previous data + * before adding a new plot + */ + getDataBuilder() { + let dataBuilder = D3LineData.builder() + .subView(this.plot.view.upperSubView); + + for (let series of this.exceedanceData.series) { + let lineOptions = D3LineOptions.builder() + .label(series.lineOptions.label) + .markerSize(series.lineOptions.markerSize) + .lineWidth(series.lineOptions.lineWidth) + .build(); + + dataBuilder.data(series.xValues, series.yValues, lineOptions); + } + + return dataBuilder; + } + + /** + * Add form input checks. + * + * @param {HTMLElement[]} formEls + */ + onInputCheck(formEls) { + Preconditions.checkArgumentArrayInstanceOf(formEls, Element); + + for (let el of formEls) { + Constraints.onInput(el, el.getAttribute('min'), el.getAttribute('max')); + } + } + + /** + * Return the mean value, ln(median) + */ + mean() { + return Math.log(this.median()); + } + + /** + * Return the median value + */ + median() { + return Number(this.medianEl.value); + } + + metadata() { + const metadata = new Map(); + + metadata.set('Median (g)', this.medianValues); + metadata.set('Sigma (natural log units)', this.sigmaValues); + metadata.set('Truncation', this.truncationValues); + metadata.set('Truncation Level (n)', this.truncationLevelValues); + + return metadata; + } + + /** + * Remove selected plot if remove selected button is clicked. + */ + plotSelection() { + let removePlotEvent = () => {}; + + this.plot.onPlotSelection(this.exceedanceData, (selectedData) => { + this.removePlotBtnEl.disabled = false; + + let plotEvent = () => { + let index = this.exceedanceData.series.findIndex(series => { + return series.lineOptions.id === selectedData.lineOptions.id; + }); + + this.exceedanceData.series.splice(index, 1); + + this.plot.clearAll(); + this.removePlotBtnEl.disabled = true; + this.removeValues(index); + this.setPlotData(this.exceedanceData); + + if(this.exceedanceData.series.length == 0) { + this.clearPlotBtnEl.disabled = true; + return; + } + + this.plot.plot(this.exceedanceData); + }; + + this.removePlotBtnEl.removeEventListener('click', removePlotEvent); + this.removePlotBtnEl.addEventListener('click', plotEvent); + + removePlotEvent = plotEvent; + }); + } + + /** + * Return the rate value + */ + rate() { + return Number(this.rateEl.value); + } + + /** + * Remove values + */ + removeValues(index) { + Preconditions.checkArgumentInteger(index); + + this.medianValues.splice(index, 1); + this.sigmaValues.splice(index, 1); + this.rateValues.splice(index, 1); + this.truncationValues.splice(index, 1); + this.truncationLevelValues.splice(index, 1); + } + + /** + * Set the default form values + */ + setDefaults() { + this.medianEl.value = this.defaults.median; + this.sigmaEl.value = this.defaults.sigma; + this.truncationLevelEl.value = this.defaults.truncationLevel; + this.truncationEl.setAttribute('checked', !this.defaults.truncation); + this.rateEl.value = this.defaults.rate; + this.truncationLevelEl.disabled = !this.truncationEl.checked; + } + + /** + * Set the plot data and metadata + * @param {D3LineData} data + */ + setPlotData(data) { + Preconditions.checkArgumentInstanceOf(data, D3LineData); + + this.plotView.setSaveData(data); + this.plotView.createDataTable(data); + this.plotView.setMetadata(this.metadata()); + this.plotView.createMetadataTable(); + } + + /** + * Return the sigma value + */ + sigma() { + return Number(this.sigmaEl.value); + } + + /** + * Return the truncation value + */ + truncation() { + return this.truncationEl.checked ? 'On' : 'Off'; + } + + /** + * Return the truncation level value + */ + truncationLevel() { + return this.truncationEl.checked ? + Number(this.truncationLevelEl.value) : 'N/A'; + } + + /** + * Update values + */ + updateValues() { + this.medianValues.push(this.median()); + this.sigmaValues.push(this.sigma()); + this.rateValues.push(this.rate()); + this.truncationValues.push(this.truncation()); + this.truncationLevelValues.push(this.truncationLevel()); + } + +} diff --git a/webapp/apps/js/GeoDeagg.js b/webapp/apps/js/GeoDeagg.js new file mode 100644 index 000000000..fffcec65a --- /dev/null +++ b/webapp/apps/js/GeoDeagg.js @@ -0,0 +1,599 @@ +'use strict'; + +import Constraints from './lib/Constraints.js'; +import D3GeoDeagg from './lib/D3GeoDeagg.js'; +import Footer from './lib/Footer.js'; +import Header from './lib/Header.js'; +import LeafletTestSitePicker from './lib/LeafletTestSitePicker.js'; +import NshmpError from './error/NshmpError.js'; +import Spinner from './lib/Spinner.js'; +import Tools from './lib/Tools.js'; + +/** +* @class GeoDeagg +* +* @fileoverview Class for the geographic deaggregation webpage, geo-deagg.html. +* The class first calls out to the deagg webservice, nshmp-haz-ws/deagg, to +* get the usage and builds the following menus: +* - Edition +* - Region +* - IMT +* - Vs30 +* The IMT and Vs30 menus are created by the common supported values in +* the edition and region. +* Bootstrap tooltips are created and updated for the latitude, longitude, +* and return period inputs. +* The inputs, latitude, longitude, and return period, are monitored. If +* a bad or out of range value, given the coorresponding min/max values in +* the usage, is entered the update button will remain disabled. Once +* all inputs are correctly entered the update button or enter can be +* pressed to render the results. +* Once the update button or enter is pressed the results are rendered +* using the D3GeoDeagg class. +* For the geographic deaggregation, only the single sources for the total +* component are graphed. +* +* @author bclayton@usgs.gov (Brandon Clayton) +*/ +export default class GeoDeagg { + + /** + * @param {!Config} config - The config file. + */ + constructor(config) { + /** @type {Config} */ + this.config = config; + /** @type {FooterOptions} */ + this.footerOptions = { + rawBtnDisable: true, + updateBtnDisable: true, + }; + /** @type {Footer} */ + this.footer = new Footer(); + this.footer.setOptions(this.footerOptions); + /** @type {Header} */ + this.header = new Header().setTitle('Geographic Deaggregation'); + /** @type {Spinner} */ + this.spinner = new Spinner(); + + /** + * @typedef {Object} GeoDeaggOptions - Geographic deagg options + * @property {String} defaultEdition - Default selected edition. + * Values: 'E2014' || 'E2008' || 'E2007' + * Default: 'E2014' + * @property {Number} defaultReturnPeriod - Default selected return + * return period. + * Values: 475 || 975 || 2475 + * Default: 2475 + */ + this.options = { + defaultEdition: 'E2014', + defaultReturnPeriod: 2475, + }; + + /** @type {String} */ + this.webServiceUrl = this.config.server.dynamic + '/nshmp-haz-ws/deagg'; + + /** @type {HTMLElement} */ + this.contentEl = document.querySelector('#content'); + /** @type {HTMLElement} */ + this.controlPanelEl = document.querySelector('#control'); + /** @type {HTMLElement} */ + this.editionEl = document.querySelector('#edition'); + /** @type {HTMLElement} */ + this.imtEl = document.querySelector('#imt'); + /** @type {HTMLElement} */ + this.inputsEl = document.querySelector('#inputs'); + /** @type {HTMLElement} */ + this.latEl = document.querySelector('#lat'); + /** @type {HTMLElement} */ + this.lonEl = document.querySelector('#lon'); + /** @type {HTMLElement} */ + this.regionEl = document.querySelector('#region'); + /** @type {HTMLElement} */ + this.returnPeriodEl = document.querySelector('#return-period'); + /** @type {HTMLElement} */ + this.returnPeriodBtnsEl = document.querySelector('#return-period-btns'); + /** @type {HTMLElement} */ + this.testSitePickerBtnEl = document.querySelector('#test-site-picker'); + /** @type {HTMLElement} */ + this.vs30El = document.querySelector('#vs30'); + + /** @type {D3GeoDeagg} */ + this.plot = this.plotSetup(); + + /** @type {Object} - Deagg usage */ + this.parameters = undefined; + // Get deagg usage + this.getUsage(); + + // Check latitude values on input + $(this.latEl).on('input', (event) => { + this.checkCoordinates(event.target); + }); + + // Check longitude values on input + $(this.lonEl).on('input', (event) => { + this.checkCoordinates(event.target); + }); + + // Update plot when update button pressed + $(this.footer.updateBtnEl).click((event) => { + $(this.footer.rawBtnEl).off(); + this.footerOptions.rawBtnDisable = false; + this.footer.setOptions(this.footerOptions); + this.updatePlot(); + }); + + // Listen for all control panel changes + this.onInputChange(); + + /* @type {LeafletTestSitePicker} */ + this.testSitePicker = new LeafletTestSitePicker( + this.latEl, + this.lonEl, + this.testSitePickerBtnEl); + + /* Bring Leaflet map up when clicked */ + $(this.testSitePickerBtnEl).on('click', (event) => { + this.testSitePicker.plotMap(this.regionEl.value); + }); + } + + /** + * @method addInputTooltip + * + * Add an input tooltip for latitude, logitude, and return period using + * Contraints.addTooltip. + */ + addInputTooltip() { + let region = Tools.stringToParameter( + this.parameters.region, this.regionEl.value); + + Constraints.addTooltip( + this.latEl, region.minlatitude, region.maxlatitude); + + Constraints.addTooltip( + this.lonEl, region.minlongitude, region.maxlongitude); + + let period = this.parameters.returnPeriod; + Constraints.addTooltip( + this.returnPeriodEl, period.values.minimum, period.values.maximum); + } + + /** + * @method buildInputs + * + * Process usage response and set select menus + * @param {!Object} usage - JSON usage response from deagg web service. + */ + buildInputs(usage) { + this.spinner.off(); + let parameters = usage.parameters; + this.parameters = parameters; + + // Set edition menu + Tools.setSelectMenu(this.editionEl, parameters.edition.values); + this.editionEl.value = this.options.defaultEdition; + + // Set menus + this.setRegionMenu(); + this.setImtMenu(); + this.setVs30Menu(); + this.setDefaultReturnPeriod(); + this.addInputTooltip(); + + $(this.controlPanelEl).removeClass('hidden'); + + // Update return period on change + this.onReturnPeriodChange(); + // Update menus when edition is changed + this.onEditionChange(); + // Update menus when region is changed + this.onRegionChange(); + // Check URL for parameters + this.testSitePicker.on('testSiteLoad', (event) => { + this.checkQuery(); + }); + } + + /** + * @method checkCoordinates + * + * Check the input values of latitude or longitude using Contraints.check + * method. If values is out of range or bad, the plot cannot be updated. + * @param {HTMLElement} el - Latitude or longitude element. + */ + checkCoordinates(el) { + let region = Tools.stringToParameter( + this.parameters.region, this.regionEl.value); + + let min = el.id == 'lat' ? region.minlatitude : + region.minlongitude; + + let max = el.id == 'lat' ? region.maxlatitude : + region.maxlongitude; + + Constraints.check(el, min, max); + } + + /** + * @method checkReturnPeriod + * + * Check the return period input value using the Contraints.check method. + * If the value is out of range or bad, the plot cannot be updated. + */ + checkReturnPeriod() { + let period = this.parameters.returnPeriod; + Constraints.check( + this.returnPeriodEl, period.values.minimum, period.values.maximum); + } + + /** + * @method checkQuery + * + * Check the hash of the URL string to see if parameters exists. If + * there are paramaters present, set the menus to match the values and + * plot the results. + */ + checkQuery() { + let url = window.location.hash.substring(1); + let urlObject = Tools.urlQueryStringToObject(url); + + // Make sure all pramameters are present in URL + if (!urlObject.hasOwnProperty('edition') || + !urlObject.hasOwnProperty('region') || + !urlObject.hasOwnProperty('latitude') || + !urlObject.hasOwnProperty('longitude') || + !urlObject.hasOwnProperty('imt') || + !urlObject.hasOwnProperty('vs30') || + !urlObject.hasOwnProperty('returnperiod')) return; + + // Set edition value and trigger a change to update the other menus + let edition = urlObject.edition; + this.editionEl.value = edition; + $(this.editionEl).trigger('change'); + delete urlObject.edition; + + // Set regions value and trigger a change to update the other menus + let region = urlObject.region; + this.regionEl.value = region; + $(this.regionEl).trigger('change'); + delete urlObject.region; + + // Update all other values + for (let key in urlObject) { + $('[name="' + key + '"]').val(urlObject[key]); + } + + this.checkCoordinates(this.latEl); + this.checkCoordinates(this.lonEl); + + // Trigger input event on return period + $(this.returnPeriodEl).trigger('input'); + + // Trigger an enter key + $(this.inputsEl).trigger('change'); + let keypress = jQuery.Event('keypress'); + keypress.which = 13; + keypress.keyCode = 13; + $(document).trigger(keypress); + } + + /** + * Get current chosen parameters. + * @return Map> The metadata Map + */ + getMetadata() { + let metadata = new Map(); + metadata.set('Edition:', [$(this.editionEl).find(':selected').text()]); + metadata.set('Region:', [$(this.regionEl).find(':selected').text()]); + metadata.set('Latitude (°):', [this.latEl.value]); + metadata.set('Longitude (°):', [this.lonEl.value]); + metadata.set('Intensity Measure Type:', [$(this.imtEl).find(':selected').text()]); + metadata.set('Vs30:', [$(this.vs30El).find(':selected').text()]); + metadata.set('Return Period (years):', [this.returnPeriodEl.value + ' years']); + + return metadata; + } + + /** + * @method getUsage + * + * Call deagg web service and get the JSON usage. Once usage is received, + * build the inputs. + */ + getUsage() { + let jsonCall = Tools.getJSON(this.webServiceUrl); + this.spinner.on(jsonCall.reject); + + jsonCall.promise.then((usage) => { + NshmpError.checkResponse(usage); + this.buildInputs(usage); + }).catch((errorMessage) => { + this.spinner.off(); + NshmpError.throwError(errorMessage); + }); + } + + /** + * @method onEditionChange + * + * Listen for the edition menu to change and update region, IMT, + * and Vs30 menus and update input tooltip for new values. + */ + onEditionChange() { + $(this.editionEl).on('change', (event) => { + this.setRegionMenu(); + this.setImtMenu(); + this.setVs30Menu(); + this.latEl.value = null; + this.lonEl.value = null; + this.addInputTooltip(); + }); + } + + /** + * @method onInputChange + * + * Listen for any menu or input to change in the control panel and check + * if any input box has bad values. If values are good allow the plot + * to be updated. + */ + onInputChange() { + $(this.inputsEl).on('change input', (event) => { + let hasError; + let val; + $(this.inputsEl).find('input').each((i, d) => { + val = parseFloat(d.value); + hasError = isNaN(val) ? true : $(d.parentNode).hasClass('has-error'); + return !hasError; + }); + this.footerOptions.updateBtnDisable = hasError; + this.footer.setOptions(this.footerOptions); + }); + } + + /** + * @method onRegionChange + * + * Listen for the region menu to change and update the IMT and Vs30 + * menus and update the input tooltips for new values. + */ + onRegionChange() { + $(this.regionEl).on('change', (event) => { + this.latEl.value = null; + this.lonEl.value = null; + this.addInputTooltip(); + this.setImtMenu(); + this.setVs30Menu(); + }); + } + /** + * @method onReturnPeriodChange + * + * Listen for a return period button to be clicked or the return period + * input to be changed. Update the value of the input with the new button + * value. If a value is input, check to see if the value matches + * any of the button values and make that button active. + */ + onReturnPeriodChange() { + // Update input with button value + $(this.returnPeriodBtnsEl).on('click', (event) => { + let el = $(event.target).closest('.btn').find('input'); + let val = el.val(); + this.returnPeriodEl.value = val; + }); + + // See if input value matches a button value + $(this.returnPeriodEl).on('input', (event) => { + this.checkReturnPeriod(); + + d3.select(this.returnPeriodBtnsEl) + .selectAll('label') + .classed('active', false) + .selectAll('input') + .select((d, i, els) => { + if (this.returnPeriodEl.value == els[i].value) { + return els[i].parentNode; + } + }) + .classed('active', true); + }); + } + + /** + * @method plotSetup + * + * Set specific plot options. + * @return {D3GeoDeagg} - New instance of the D3GeoDeagg class for plotting + * results. + */ + plotSetup() { + let viewOptions = {}; + let upperPlotOptions = { + marginBottom: 0, + marginLeft: 0, + marginRight: 0, + marginTop: 10, + }; + let lowerPlotOptions = {}; + + return new D3GeoDeagg( + this.contentEl, + viewOptions, + upperPlotOptions, + lowerPlotOptions) + .withPlotHeader() + .withPlotFooter(); + } + + /** + * @method serializeUrl + * + * Get all current values from the control panel and serialize using + * the name attribute. Update the hash part of the URL to the + * key value pairs. + * @return {String} - URL to call webservices. + */ + serializeUrl() { + let inputs = $(this.inputsEl).serialize(); + let url = this.webServiceUrl + '?' + inputs; + window.location.hash = inputs; + + return url; + } + + /** + * @method setDefaultReturnPeriod + * + * Set the default return period value and button. + */ + setDefaultReturnPeriod() { + d3.select(this.returnPeriodBtnsEl) + .selectAll('input') + .filter('input[value="' + this.options.defaultReturnPeriod + '"]') + .select((d, i, els) => { return els[0].parentNode }) + .classed('active', true); + this.returnPeriodEl.value = this.options.defaultReturnPeriod; + } + + /** + * @method setRegionMenu + * + * Set the region select menu given the supported regions from the + * selected edition. + */ + setRegionMenu() { + let selectedEdition = Tools.stringToParameter( + this.parameters.edition, this.editionEl.value); + + let supportedRegions = Tools.stringArrayToParameters( + this.parameters.region, selectedEdition.supports.region); + + Tools.setSelectMenu(this.regionEl, supportedRegions); + } + + /** + * @method setImtMenu + * + * Set the IMT select menu given the common supported IMT values from the + * selected edition and region. + */ + setImtMenu() { + let selectedEdition = Tools.stringToParameter( + this.parameters.edition, this.editionEl.value); + + let selectedRegion = Tools.stringToParameter( + this.parameters.region, this.regionEl.value); + + let supports = []; + supports.push(selectedEdition.supports.imt) + supports.push(selectedRegion.supports.imt); + + let supportedImts = Tools.supportedParameters( + this.parameters.imt, supports); + + Tools.setSelectMenu(this.imtEl, supportedImts); + } + + /** + * @method setVs30 + * + * Set the VS30 select menu given the common supported Vs30 values from the + * selected edition and region. + */ + setVs30Menu() { + let selectedEdition = Tools.stringToParameter( + this.parameters.edition, this.editionEl.value); + + let selectedRegion = Tools.stringToParameter( + this.parameters.region, this.regionEl.value); + + let supports = []; + supports.push(selectedEdition.supports.vs30) + supports.push(selectedRegion.supports.vs30); + + let supportedVs30 = Tools.supportedParameters( + this.parameters.vs30, supports); + + Tools.setSelectMenu(this.vs30El, supportedVs30); + } + + /** + * @method updatePlot + * + * Call the deagg web service and plot the single source total component + * values using the D3GeoDeagg plotting class. + */ + updatePlot() { + let url = this.serializeUrl(); + let metadata = this.getMetadata(); + + let jsonCall = Tools.getJSON(url); + this.spinner.on(jsonCall.reject, 'Calculating'); + + jsonCall.promise.then((response) => { + this.spinner.off(); + NshmpError.checkResponse(response, this.plot); + + this.footer.setMetadata(response.server); + + // Find total data component + let totalData = response.response[0].data.find((d, i) => { + return d.component == 'Total'; + }); + + // Find the single sources + let singleSources = totalData.sources.filter((d, i) => { + return d.type == 'SINGLE'; + }); + + let seriesData = []; + let seriesLabels = []; + let seriesIds = []; + + singleSources.forEach((d, i) => { + seriesData.push([[d.longitude, d.latitude, d.contribution]]); + seriesLabels.push(d.name); + seriesIds.push(d.name.replace(/ /g, '_')); + }); + + metadata.set('url', [window.location.href]); + metadata.set('date', [response.date]); + + let lat = response.response[0].metadata.latitude; + let lon = response.response[0].metadata.longitude; + // Where the map should rotate to + let rotate = [-lon, -lat, 0]; + + let siteTitle = this.testSitePicker + .getTestSiteTitle(this.regionEl.value); + let vs30 = $(':selected', this.vs30El).text(); + let imt = $(':selected', this.imtEl).text(); + let edition = $(':selected', this.editionEl).text(); + + let title = edition + ', ' + siteTitle + ', ' + imt + ', ' + vs30; + + this.plot.setPlotTitle(title) + .setSiteLocation({latitude: lat, longitude: lon}) + .setMetadata(metadata) + .setUpperData(seriesData) + .setUpperPlotFilename('geoDeagg') + .setUpperDataTableTitle('Deaggregation Contribution') + .setUpperPlotIds(seriesIds) + .setUpperPlotLabels(seriesLabels) + .plotData(this.plot.upperPanel, rotate); + + $(this.footer.rawBtnEl).off(); + $(this.footer.rawBtnEl).on('click', (event) => { + window.open(url); + }); + + }).catch((errorMessage) => { + this.spinner.off(); + NshmpError.throwError(errorMessage); + }); + } + +} diff --git a/webapp/apps/js/GmmDistance.js b/webapp/apps/js/GmmDistance.js new file mode 100644 index 000000000..f66886149 --- /dev/null +++ b/webapp/apps/js/GmmDistance.js @@ -0,0 +1,241 @@ + +import { D3LineData } from './d3/data/D3LineData.js'; +import { D3LineOptions } from './d3/options/D3LineOptions.js'; +import { D3LinePlot } from './d3/D3LinePlot.js'; +import { D3LineSubViewOptions } from './d3/options/D3LineSubViewOptions.js'; +import { D3LineView } from './d3/view/D3LineView.js'; +import { D3LineSubView } from './d3/view/D3LineSubView.js'; +import { D3LineLegendOptions } from './d3/options/D3LineLegendOptions.js'; + +import { Gmm } from './lib/Gmm.js'; +import NshmpError from './error/NshmpError.js'; +import Tools from './lib/Tools.js'; + +/** +* @class GmmDistance +* @extends Gmm +* +* @fileoverview Class for gmm-distance..html, ground motion Vs. +* distance web app. +* This class plots the results of nshmp-haz-ws/gmm/distance web service. +* This class will first call out to nshmp-haz-ws/gmm/distance web service +* to obtain the usage and create the control panel with the following: +* - Ground motions models +* - Intensity measure type +* - Magnitude +* - zTop +* - Dip +* - Width +* - Vs30 +* - Vs30 measured or inferred +* - Z1.0 +* - Z2.5 +* Once the control panel is set, it can be used to select desired +* parameters and plot ground motion vs. distance. +* Already defined DOM elements: +* - #gmms +* - .gmm-alpha +* - .gmm-group +* - #gmm-sorter +* - #inputs +* - #Mw +* - #vs30 +* - #z1p0 +* - #z2p5 +* +* @author bclayton@usgs.gov (Brandon Clayton) +*/ +export class GmmDistance extends Gmm { + + /** + * @param {HTMLElement} contentEl - Container element to put plots + */ + constructor(config) { + let webServiceUrl = '/nshmp-haz-ws/gmm/distance'; + let webApp = 'GmmDistance'; + super(webApp, webServiceUrl, config); + this.header.setTitle('Ground Motion Vs. Distance'); + + /** + * @type {{ + * rMaxDefault: {number} - Maximum distance, + * rMinDefault: {number} - Minimum distance, + * }} Object + */ + this.options = { + rMax: 300, + rMin: 0.1, + }; + + /** @type {number} */ + this.rMax = this.options.rMax; + /** @type {number} */ + this.rMin = this.options.rMin; + + /** @type {HTMLElement} */ + this.contentEl = document.querySelector('#content'); + /** @type {HTMLElement} */ + this.dipEl = document.querySelector('#dip'); + /** @type {HTMLElement} */ + this.imtEl = document.querySelector('#imt'); + /** @type {HTMLElement} */ + this.widthEl = document.querySelector('#width'); + /** @type {HTMLElement} */ + this.zTopEl = document.querySelector('#zTop'); + + this.gmmView = this.setupGMMView(); + this.gmmLinePlot = new D3LinePlot(this.gmmView); + + $(this.imtEl).change((event) => { this.imtOnChange(); }); + + this.getUsage(); + } + + /** + * Get current chosen parameters. + * @return {Map>} The metadata Map + */ + getMetadata() { + let gmms = this.getCurrentGmms(); + + let metadata = new Map(); + metadata.set('Ground Motion Model:', gmms); + metadata.set('Intensity Measure Type:', [$(this.imtEl).find(':selected').text()]); + metadata.set('MW:', [this.MwEl.value]); + metadata.set('ZTop (km):', [this.zTopEl.value]); + metadata.set('Dip (°):', [this.dipEl.value]); + metadata.set('Width (km):', [this.widthEl.value]); + metadata.set('Minimum Rupture Distance (km):', [this.rMin]); + metadata.set('Maximum Rupture Distance (km):', [this.rMax]); + metadata.set('VS30 (m/s):', [this.vs30El.value]); + metadata.set('Z1.0 (km):', [this.z1p0El.value]); + metadata.set('Z2.5 (km):', [this.z2p5El.value]); + + return metadata; + } + + /** + * Plot the ground motion vs. distance response. + * + * @param {Object} response + */ + plotGMM(response) { + this.gmmLinePlot.clearAll(); + let lineData = this.responseToLineData(response, this.gmmView.upperSubView); + this.gmmLinePlot.plot(lineData); + + let metadata = this.getMetadata(); + this.gmmView.setMetadata(metadata); + this.gmmView.createMetadataTable(); + + this.gmmView.setSaveData(lineData); + this.gmmView.createDataTable(lineData); + } + + /** + * Convert the response to line data. + * + * @param {Object} response The response + * @param {D3LineSubView} subView The sub view to plot the line data + */ + responseToLineData(response, subView) { + let dataBuilder = D3LineData.builder().subView(subView); + + for (let responseData of response.means.data) { + let lineOptions = D3LineOptions.builder() + .id(responseData.id) + .label(responseData.label) + .markerSize(4) + .build(); + + dataBuilder.data(responseData.data.xs, responseData.data.ys, lineOptions); + } + + return dataBuilder.build(); + } + + /** + * @override + * @method serializeGmmUrl + * + * Serialize all forms for ground motion web wervice and set + * set the hash of the window location to reflect the form values. + */ + serializeGmmUrl(){ + let controlInputs = $(this.inputsEl).serialize(); + let inputs = controlInputs + '&' + + '&rMin=' + this.rMin + + '&rMax=' + this.rMax; + let dynamic = this.config.server.dynamic; + let url = dynamic + this.webServiceUrl + '?' + inputs; + window.location.hash = inputs; + + return url; + } + + /** + * Setup the plot view + */ + setupGMMView() { + /* Upper sub view legend options */ + let legendOptions = D3LineLegendOptions.upperBuilder() + .location('bottom-left') + .build(); + + /* Upper sub view options: gmm vs distance */ + let upperSubViewOptions = D3LineSubViewOptions.upperBuilder() + .filename('gmm-distance') + .label('Ground Motion Vs. Distance') + .legendOptions(legendOptions) + .lineLabel('Ground Motion Model') + .xAxisScale('log') + .xLabel('Distance (km)') + .yAxisScale('log') + .yLabel('Median Ground Motion (g)') + .build(); + + let view = D3LineView.builder() + .containerEl(this.contentEl) + .upperSubViewOptions(upperSubViewOptions) + .build(); + + view.setTitle('Ground Motion Vs. Distance'); + + return view; + } + + /** + * Call the ground motion web service and plot the results + */ + updatePlot() { + let url = this.serializeGmmUrl(); + + // Call ground motion gmm/distance web service + let jsonCall = Tools.getJSON(url); + this.spinner.on(jsonCall.reject, 'Calculating'); + + jsonCall.promise.then((response) => { + this.spinner.off(); + NshmpError.checkResponse(response, this.plot); + + this.footer.setMetadata(response.server); + + let selectedImt = $(':selected', this.imtEl); + let selectedImtDisplay = selectedImt.text(); + + this.gmmView.setTitle(`Ground Motion Vs. Distance: ${selectedImtDisplay}`); + + this.plotGMM(response); + + $(this.footer.rawBtnEl).off() + $(this.footer.rawBtnEl).click((event) => { + window.open(url); + }); + }).catch((errorMessage) => { + this.spinner.off(); + this.gmmLinePlot.clearAll(); + NshmpError.throwError(errorMessage); + }); + } + +} diff --git a/webapp/apps/js/HwFw.js b/webapp/apps/js/HwFw.js new file mode 100644 index 000000000..60edfe7f9 --- /dev/null +++ b/webapp/apps/js/HwFw.js @@ -0,0 +1,568 @@ + +import { D3LineData } from './d3/data/D3LineData.js'; +import { D3LineOptions } from './d3/options/D3LineOptions.js'; +import { D3LinePlot } from './d3/D3LinePlot.js'; +import { D3LineSubViewOptions } from './d3/options/D3LineSubViewOptions.js'; +import { D3LineView } from './d3/view/D3LineView.js'; +import { D3LineSubView } from './d3/view/D3LineSubView.js'; +import { D3LineViewOptions } from './d3/options/D3LineViewOptions.js'; +import { D3SaveFigureOptions } from './d3/options/D3SaveFigureOptions.js'; + +import Constraints from './lib/Constraints.js'; +import { Gmm } from './lib/Gmm.js'; +import Tools from './lib/Tools.js'; +import NshmpError from './error/NshmpError.js'; + +/** +* @class HwFw +* @extends Gmm +* +* @fileoverview Class for hw-fw.html, hanging wall effects web app. +* This class plots the results of nshmp-haz-ws/gmm/hw-fw web service. +* This class will first call out to nshmp-haz-ws/gmm/hw-fw web service +* to obtain the usage and create the control panel with the following: +* - Ground motions models +* - Intensity measure type +* - Magnitude +* - Vs30 +* - Vs30 measured or inferred +* - Z1.0 +* - Z2.5 +* Once the control panel is set, it can be used to select desired +* parameters and plot ground motion vs. distance. +* A fault plane is shown underneath the ground motion vs. distance plot. +* To show hanging wall effects, three range sliders are shown next to the +* fault plane and control the fault plane's: +* - Dip (range: 0-90) +* - Width (range: 1-30km) +* - zTop (range: 0-10km) +* The fault plane is limited to having a fault bottom of 20km. +* Once the fault plane is changed with either of the sliders, the +* ground motions vs. distance plot is updated automatically. +* +* @author bclayton@usgs.gov (Brandon Clayton) +*/ +export class HwFw extends Gmm { + + /** + * @param {HTMLElement} contentEl - Container element to put plots + */ + constructor(config) { + let webServiceUrl = '/nshmp-haz-ws/gmm/hw-fw'; + let webApp = 'HwFw'; + super(webApp, webServiceUrl, config); + this.header.setTitle('Hanging Wall Effects'); + + /** + * @type {{ + * lowerPlotWidth: {number} - Lower plot width in percentage, + * minDip: {number} - Minimum dip allowed in degrees, + * minWidth: {number} - Minimum width allowed in km, + * minZTop: {number} - Minimum zTop allowed in km, + * maxDip: {number} - Maximum dip allowed in degrees, + * maxWidth: {number} - Maximum width allowed in km, + * maxZTop: {number} - Maximum zTop allowed in km, + * maxFaultBottom: {number} - Maximum fault bottom allowed in km, + * rMaxDefault: {number} - Maximum distance, + * rMinDefault: {number} - Minimum distance, + * stepDip: {number} - Step in dip in degrees, + * stepWidth: {number} - Step in width in km, + * stepZTop: {number} - step in zTop in km + * }} Object + */ + this.options = { + lowerPlotWidth: 0.65, + minDip: 10, + minWidth: 1, + minZTop: 0, + maxDip: 90, + maxWidth: 30, + maxZTop: 10, + maxFaultBottom: 20, + rMax: 70, + rMin: -20, + stepDip: 5, + stepWidth: 0.5, + stepZTop: 0.5, + }; + + /** @type {number} */ + this.rMax = this.options.rMax; + /** @type {number} */ + this.rMin = this.options.rMin; + + /** @type {HTMLElement} */ + this.contentEl = document.querySelector("#content"); + /** @type {HTMLElement} */ + this.dipEl = undefined; + /** @type {HTMLElement} */ + this.dipSliderEl = undefined; + /** @type {HTMLElement} */ + this.imtEl = document.querySelector('#imt'); + /** @type {HTMLElement} */ + this.widthEl = undefined; + /** @type {HTMLElement} */ + this.widthSliderEl = undefined; + /** @type {HTMLElement} */ + this.zTopEl = undefined; + /** @type {HTMLElement} */ + this.zTopSliderEl = undefined; + + this.xLimit = [ this.rMin, this.rMax ]; + + this.gmmView = this.setupGMMView(); + this.gmmLinePlot = new D3LinePlot(this.gmmView); + + this.faultXLimit = this.gmmView.lowerSubView.options.defaultXLimit; + this.faultYLimit = [ 0, this.options.maxFaultBottom ]; + + $(this.imtEl).change((event) => { this.imtOnChange(); }); + + this.faultSliders(); + + this.getUsage(this.setSliderValues); + } + + /** + * @method checkFaultExtent + * + * Check to see if the fault plane is or well be out of the + * defined fault bottom maximum + * @return {{ + * maxDip: {number} - Max dip allowed given other values, + * maxWidth: {number} - Max width allowed given other values, + * maxZTop: {number} - Max zTop allowed given other values, + * pastExtent: {Boolean} + * }} + */ + checkFaultExtent() { + let faultCheck = {}; + let dip = this.dip_val(); + let width = this.width_val(); + let zTop = this.zTop_val(); + let faultBottom = width * Math.sin(dip) + zTop; + let maxFaultBottom = this.options.maxFaultBottom; + faultCheck.maxDip = Math.asin((maxFaultBottom - zTop) / width); + faultCheck.maxDip = faultCheck.maxDip * 180.0 / Math.PI; + faultCheck.maxDip = isNaN(faultCheck.maxDip) ? 90 : faultCheck.maxDip; + faultCheck.maxWidth = (maxFaultBottom - zTop) / Math.sin(dip); + faultCheck.maxZTop = maxFaultBottom - width * Math.sin(dip); + faultCheck.pastExtent = faultBottom > maxFaultBottom ? true : false; + + return faultCheck; + } + + /** + * @method faultSliders + * + * Create range sliders for the fault plane plot + */ + faultSliders() { + let sliderInfo = [ + { + name: 'Dip', + sliderId: 'dip-slider', + valueId: 'dip', + min: this.options.minDip, + max: this.options.maxDip, + step: this.options.stepDip, + unit: '°', + },{ + name: 'Width', + sliderId: 'width-slider', + valueId: 'width', + min: this.options.minWidth, + max: this.options.maxWidth, + step: this.options.stepWidth, + unit: 'km', + },{ + name: 'zTop', + sliderId: 'zTop-slider', + valueId: 'zTop', + min: this.options.minZTop, + max: this.options.maxZTop, + step: this.options.stepZTop, + unit: 'km', + } + ]; + + let width = (1 - this.options.lowerPlotWidth) * 100; + d3.select(this.gmmView.lowerSubView.svg.svgEl) + .style('margin-right', width + '%'); + + let faultFormD3 = d3.select(this.gmmView.lowerSubView.subViewBodyEl) + .append('form') + .attr('class', 'form fault-form'); + + let divD3 = faultFormD3.selectAll('div') + .data(sliderInfo) + .enter() + .append('div') + .attr('class', 'slider-form'); + + divD3.append('label') + .attr('for', (d,i) => { return d.sliderId }) + .text((d,i) => { return d.name }); + + let formD3 = divD3.append('div') + .attr('class', 'row'); + + formD3.append('div') + .attr('class', 'col-sm-12 col-md-12 col-lg-8') + .html((d,i) => { + return '' + }); + + formD3.append('div') + .attr('class', 'col-sm-12 col-md-6 col-lg-4') + .append('div') + .attr('class', 'input-group input-group-sm') + .html((d,i) => { + return '' + + ' ' + d.unit + ' '; + }); + + this.dipSliderEl = document.querySelector('#dip-slider'); + this.dipEl = document.querySelector('#dip'); + this.faultFormEl = document.querySelector('.fault-form'); + this.widthSliderEl = document.querySelector('#width-slider'); + this.widthEl = document.querySelector('#width'); + this.zTopSliderEl = document.querySelector('#zTop-slider'); + this.zTopEl = document.querySelector('#zTop'); + + // Update tooltips + Constraints.addTooltip( + this.dipEl, this.options.minDip, this.options.maxDip); + Constraints.addTooltip( + this.widthEl, this.options.minWidth, this.options.maxWidth); + Constraints.addTooltip( + this.zTopEl, this.options.minZTop, this.options.maxZTop); + + // Listen for changes on fault form inputs and sliders + $('.fault-form').bind('input keyup mouseup', (event) => { + this.inputsOnInput(); + this.faultSliderOnChange(event) + }); + } + + /** + * @method faultSlidersOnChange + * + * Update the fault plane plot with change in each slider or input + * field and update ground motion Vs. distance plot if inputted + * values are good. + * @param {!Event} event - Event that triggered the change + */ + faultSliderOnChange(event) { + let minVal; + let maxVal; + let maxValStr; + let parEl; + let step; + let sliderEl; + let valueEl; + + let id = event.target.id; + let value = parseFloat(event.target.value); + let inputType = event.target.type; + let eventType = event.type; + + if (!id || id.length == 0 || isNaN(value)){ + return; + } + + if (id == this.dipSliderEl.id || id == this.dipEl.id) { + parEl = this.dipEl; + sliderEl = this.dipSliderEl; + valueEl = this.dipEl; + maxValStr = 'maxDip'; + step = this.options.stepDip; + maxVal = this.options.maxDip; + minVal = this.options.minDip; + + } else if (id == this.widthSliderEl.id || id == this.widthEl.id) { + parEl = this.widthEl; + sliderEl = this.widthSliderEl; + valueEl = this.widthEl; + step = this.options.stepWidth; + maxValStr = 'maxWidth'; + maxVal = this.options.maxWidth; + minVal = this.options.minWidth; + } else if (id == this.zTopSliderEl.id || id == this.zTopEl.id) { + parEl = this.zTopEl; + sliderEl = this.zTopSliderEl; + valueEl = this.zTopEl; + maxValStr = 'maxZTop'; + step = this.options.stepZTop; + maxVal = this.options.maxZTop; + minVal = this.options.minZTop; + } + + let canSubmit = Constraints.check(valueEl, minVal, maxVal); + if (!canSubmit) return; + + parEl.value = value; + sliderEl.value = value; + valueEl.value = value; + let faultCheck = this.checkFaultExtent(); + if (faultCheck.pastExtent) { + // Round down to nearest step + event.target.value = + Math.round((faultCheck[maxValStr] - step) / step) * step; + valueEl.value = event.target.value; + parEl.value = event.target.value; + return; + } + + this.plotFaultPlane(); + if (inputType == 'range' && + (eventType == 'keyup' || eventType == 'mouseup')) { + this.updatePlot(); + } else if (inputType != 'range') { + this.updatePlot(); + } + } + + /** + * Get current fault plane line data + */ + getFaultPlaneLineData() { + let dip = this.dip_val(); + let width = this.width_val(); + let zTop = this.zTop_val(); + + let xMin = 0; + let xMax = width * Math.cos(dip); + let x = [xMin, Number(xMax.toFixed(4))]; + + let yMin = zTop; + let yMax = width * Math.sin(dip) + zTop; + let y = [yMin, Number(yMax.toFixed(4))]; + + let lineOptions = D3LineOptions.builder() + .id('fault') + .label('Fault Plane') + .markerSize(0) + .build(); + + let lineData = D3LineData.builder() + .subView(this.gmmView.lowerSubView) + .data(x, y, lineOptions) + .xLimit(this.faultXLimit) + .yLimit(this.faultYLimit) + .build(); + + return lineData; + } + + /** + * Get current chosen parameters. + * @return {Map>} The metadata Map + */ + getMetadata() { + let gmms = this.getCurrentGmms(); + + let metadata = new Map(); + metadata.set('Ground Motion Model:', gmms); + metadata.set('Intensity Measure Type:', [$(this.imtEl).find(':selected').text()]); + metadata.set('MW:', [this.MwEl.value]); + metadata.set('ZTop (km):', [this.zTopEl.value]); + metadata.set('Dip (°):', [this.dipEl.value]); + metadata.set('Width (km):', [this.widthEl.value]); + metadata.set('Minimum Rupture Distance (km):', [this.rMin]); + metadata.set('Maximum Rupture Distance (km):', [this.rMax]); + metadata.set('VS30 (m/s):', [this.vs30El.value]); + metadata.set('Z1.0 (km):', [this.z1p0El.value]); + metadata.set('Z2.5 (km):', [this.z2p5El.value]); + + return metadata; + } + + plotFaultPlane() { + this.gmmLinePlot.clear(this.gmmView.lowerSubView); + let lineData = this.getFaultPlaneLineData(); + this.gmmLinePlot.plot(lineData); + } + + /** + * Plot the ground motion vs. distance response. + * + * @param {Object} response + */ + plotGMM(response) { + this.gmmLinePlot.clear(this.gmmView.upperSubView); + let lineData = this.responseToLineData(response, this.gmmView.upperSubView); + this.gmmLinePlot.plot(lineData); + + let metadata = this.getMetadata(); + this.gmmView.setMetadata(metadata); + this.gmmView.createMetadataTable(); + + this.gmmView.setSaveData(lineData); + this.gmmView.createDataTable(lineData); + + this.plotFaultPlane(); + } + + /** + * Convert the response to line data. + * + * @param {Object} response The response + * @param {D3LineSubView} subView The sub view to plot the line data + */ + responseToLineData(response, subView) { + let dataBuilder = D3LineData.builder().subView(subView).xLimit(this.xLimit); + + for (let responseData of response.means.data) { + let lineOptions = D3LineOptions.builder() + .id(responseData.id) + .label(responseData.label) + .markerSize(4) + .build(); + + dataBuilder.data(responseData.data.xs, responseData.data.ys, lineOptions); + } + + return dataBuilder.build(); + } + + + /** + * Setup the plot view + */ + setupGMMView() { + /* Upper sub view options: gmm vs distance */ + let upperSubViewOptions = D3LineSubViewOptions.upperBuilder() + .defaultXLimit(this.xLimit) + .filename('hanging-wall-effects') + .label('Ground Motion Vs. Distance') + .lineLabel('Ground Motion Model') + .xAxisScale('linear') + .xLabel('Distance (km)') + .yAxisScale('linear') + .yLabel('Median Ground Motion (g)') + .build(); + + let lowerPlotWidth = Math.floor(upperSubViewOptions.plotWidth * this.options.lowerPlotWidth); + + let margins = upperSubViewOptions.paddingLeft + + upperSubViewOptions.paddingRight + upperSubViewOptions.marginLeft + + upperSubViewOptions.marginRight; + + let upperXWidth = upperSubViewOptions.plotWidth - margins; + let lowerXWidth = lowerPlotWidth - margins; + + /* Calculate lower plot X limit to match upper plot */ + let lowerXMax = ((lowerXWidth * (this.rMax - this.rMin)) / upperXWidth) + this.rMin; + + /* Lower sub view save figure options */ + let lowerSaveOptions = D3SaveFigureOptions.builder() + .addTitle(false) + .build(); + + /* Lower sub view options: fault plane */ + let lowerSubViewOptions = D3LineSubViewOptions.lowerBuilder() + .defaultXLimit([ this.options.rMin, lowerXMax ]) + .filename('fault-plane') + .label('Ground Motion Vs. Distance') + .lineLabel('Ground Motion Model') + .marginBottom(20) + .marginTop(20) + .paddingTop(30) + .plotWidth(lowerPlotWidth) + .saveFigureOptions(lowerSaveOptions) + .showLegend(false) + .xAxisLocation('top') + .xAxisNice(false) + .xAxisScale('linear') + .xLabel('Distance (km)') + .xTickMarks(Math.floor(upperSubViewOptions.xTickMarks / 2)) + .yAxisReverse(true) + .yAxisScale('linear') + .yLabel('Median Ground Motion (g)') + .build(); + + let viewOptions = D3LineViewOptions.builder() + .disableXAxisBtns(true) + .syncYAxisScale(false) + .build(); + + let view = D3LineView.builder() + .addLowerSubView(true) + .containerEl(this.contentEl) + .upperSubViewOptions(upperSubViewOptions) + .lowerSubViewOptions(lowerSubViewOptions) + .viewOptions(viewOptions) + .build(); + + view.setTitle('Hanging Wall Effects'); + + return view; + } + + /** + * @method setSliderValues + * + * Set the slider values to match the input fields + */ + setSliderValues() { + this.dipSliderEl.value = this.dipEl.value; + this.widthSliderEl.value = this.widthEl.value; + this.zTopSliderEl.value = this.zTopEl.value; + } + + /** + * @override + * @method serializeGmmUrl + * + * Serialize all forms for ground motion web wervice and set + * set the hash of the window location to reflect the form values. + */ + serializeGmmUrl(){ + let controlInputs = $(this.inputsEl).serialize(); + let faultInputs = $(this.faultFormEl).serialize(); + let inputs = controlInputs + '&' + faultInputs + + '&rMin=' + this.rMin + + '&rMax=' + this.rMax; + let dynamic = this.config.server.dynamic; + let url = dynamic + this.webServiceUrl + '?' + inputs; + window.location.hash = inputs; + + return url; + } + + /** + * Call the ground motion web service and plot the results + */ + updatePlot() { + let url = this.serializeGmmUrl(); + // Call ground motion hw-fw web service + let jsonCall = Tools.getJSON(url); + this.spinner.on(jsonCall.reject, 'Calculating'); + + jsonCall.promise.then((response) => { + this.spinner.off(); + this.footer.setMetadata(response.server); + + let selectedImt = $(':selected', this.imtEl); + let selectedImtDisplay = selectedImt.text(); + this.gmmView.setTitle(`Hanging Wall Effects: ${selectedImtDisplay}`); + + this.plotGMM(response); + + $(this.footer.rawBtnEl).off() + $(this.footer.rawBtnEl).click((event) => { + window.open(url); + }); + }).catch((errorMessage) => { + this.spinner.off(); + NshmpError.throwError(errorMessage); + }); + } + +} diff --git a/webapp/apps/js/ModelCompare.js b/webapp/apps/js/ModelCompare.js new file mode 100644 index 000000000..02ffd157d --- /dev/null +++ b/webapp/apps/js/ModelCompare.js @@ -0,0 +1,286 @@ + +import { D3LineData } from './d3/data/D3LineData.js'; +import { D3LineLegendOptions } from './d3/options/D3LineLegendOptions.js'; +import { D3LineOptions } from './d3/options/D3LineOptions.js'; +import { D3LinePlot } from './d3/D3LinePlot.js'; +import { D3LineSubViewOptions } from './d3/options/D3LineSubViewOptions.js'; +import { D3LineView } from './d3/view/D3LineView.js'; +import { D3LineViewOptions } from './d3/options/D3LineViewOptions.js'; + +import { Hazard } from './lib/Hazard.js'; +import NshmpError from './error/NshmpError.js'; + +export class ModelCompare extends Hazard { + + constructor(config) { + super(config); + + this.header.setTitle("Model Comparison"); + + this.options = { + type: "compare", + regionDefault: "COUS", + imtDefault: "PGA", + vs30Default: 760, + }; + + this.contentEl = document.querySelector('#content'); + this.hazardPlotTitle = 'Hazard Curves'; + + /* Plot view options */ + this.viewOptions = D3LineViewOptions.builder() + .titleFontSize(14) + .build(); + + /* Hazard curve plot setup */ + this.hazardView = this.setupHazardView(); + this.hazardLinePlot = new D3LinePlot(this.hazardView); + + this.comparableRegions = [ + { + display: "Alaska", + value: "AK", + staticValue: "AK0P10", + dynamicValue: "AK" + }, { + display: "Central & Eastern US", + value: "CEUS", + staticValue: "CEUS0P10", + dynamicValue: "CEUS" + }, { + display: "Conterminous US", + value: "COUS", + staticValue: "COUS0P05", + dynamicValue: "COUS" + }, { + display: "Western US", + value: "WUS", + staticValue: "WUS0P05", + dynamicValue: "WUS" + } + ]; + + let setParameters = (par) => { + this.parameters = par; + this.buildInputs() + }; + + this.getHazardParameters(setParameters); + + $(this.footer.updateBtnEl).click(() => { + this.callHazard((result) => { this.callHazardCallback(result) }); + }); + + } + + /** + * Build the view for the hazard curves. + * + * @returns {D3LineView} The hazard line view + */ + setupHazardView() { + /* Upper sub view legend options: hazard plot */ + let upperLegendOptions = D3LineLegendOptions.upperBuilder() + .location('bottom-left') + .build(); + + /* Upper sub view options: hazard plot */ + let upperSubViewOptions = D3LineSubViewOptions.upperBuilder() + .filename('hazard-compare') + .label('Hazard Curves') + .lineLabel('Edition') + .legendOptions(upperLegendOptions) + .xAxisScale('log') + .xLabel('Ground Motion (g)') + .yAxisScale('log') + .yLabel('Annual Frequency of Exceedence') + .yValueToExponent(true) + .build(); + + /* Build the view */ + let view = D3LineView.builder() + .addLowerSubView(false) + .containerEl(this.contentEl) + .viewOptions(this.viewOptions) + .upperSubViewOptions(upperSubViewOptions) + .build(); + + view.setTitle(this.hazardPlotTitle); + + return view; + } + + buildInputs() { + this.spinner.off(); + + this.testSitePicker.on('testSiteLoad', (event) => { + this.checkQuery(); + }); + + this.setParameterMenu("region", this.comparableRegions); + this.setBounds(); + + let supportedEditions = this.supportedEditions(); + this.setParameterMenu("edition", supportedEditions); + d3.select(this.editionEl) + .selectAll("option") + .attr("selected", true); + + let supportedImt = this.supportedValues("imt"); + let supportedVs30 = this.supportedValues("vs30"); + this.setParameterMenu("imt", supportedImt); + this.setParameterMenu("vs30", supportedVs30); + + $(this.regionEl).change(() => { + this.hazardLinePlot.clearAll(); + + this.clearCoordinates(); + this.setBounds(); + supportedEditions = this.supportedEditions(); + this.setParameterMenu("edition", supportedEditions); + d3.select(this.editionEl) + .selectAll("option") + .attr("selected", true); + + supportedImt = this.supportedValues("imt"); + supportedVs30 = this.supportedValues("vs30"); + this.setParameterMenu("imt", supportedImt); + this.setParameterMenu("vs30", supportedVs30); + }); + + $(this.editionEl).change(() => { + supportedImt = this.supportedValues("imt"); + supportedVs30 = this.supportedValues("vs30"); + this.setParameterMenu("imt", supportedImt); + this.setParameterMenu("vs30", supportedVs30); + }); + + $(this.controlEl).removeClass('hidden'); + + let canSubmit = this.checkQuery(); + if (canSubmit) this.callHazard((result) => { this.callHazardCallback(result) }); + } + + /** + * Get the metadata + * @return {Map>} The metadata Map + */ + getMetadata() { + let editionVals = $(this.editionEl).val(); + let editions = []; + editionVals.forEach((val) => { + editions.push(d3.select('#' + val).text()); + }); + + let metadata = new Map(); + metadata.set('Region:', [$(this.regionEl).find(':selected').text()]); + metadata.set('Edition:', editions); + metadata.set('Latitude (°):', [this.latEl.value]); + metadata.set('Longitude (°):', [this.lonEl.value]); + metadata.set('Intensity Measure Type:', [$(this.imtEl).find(':selected').text()]); + metadata.set('VS30:', [$(this.vs30El).find(':selected').text()]); + + return metadata; + } + + supportedEditions() { + var selectedRegion = this.comparableRegions.find((region) => { + return region.value == this.regionEl.value; + }); + var supportedEditions = this.parameters.edition + .values.filter((editionValue) => { + return editionValue.supports.region.find((regionValue) => { + return regionValue == selectedRegion.staticValue || + regionValue == selectedRegion.dynamicValue; + }) + }); + + return supportedEditions; + } + + plotHazardCurves(hazardResponse) { + this.spinner.off(); + this.hazardLinePlot.clearAll(); + let lineData = this.hazardResponseToLineData(hazardResponse); + + this.hazardLinePlot.plot(lineData); + this.updatePlotTitle(this.hazardView); + + let metadata = this.getMetadata(); + this.hazardView.setMetadata(metadata); + this.hazardView.createMetadataTable(); + + this.hazardView.setSaveData(lineData); + this.hazardView.createDataTable(lineData); + } + + /** + * + * @param {D3LineView} view + */ + updatePlotTitle(view) { + let imt = $(':selected', this.imtEl).text(); + let vs30 = $(':selected', this.vs30El).text(); + let siteTitle = this.testSitePicker.getTestSiteTitle(this.region()); + let title = `${siteTitle}, ${imt}, ${vs30}`; + + view.setTitle(title); + } + + callHazardCallback(hazardReturn) { + this.plotHazardCurves(hazardReturn); + $(this.imtEl).off(); + $(this.imtEl).change(() => { + this.plotHazardCurves(hazardReturn); + }); + + } + + hazardResponseToLineData(hazardResponses) { + let dataBuilder = D3LineData.builder() + .subView(this.hazardView.upperSubView) + .removeSmallValues(this.Y_MIN_CUTOFF); + + if (hazardResponses.length > 10) { + dataBuilder.colorScheme(d3.schemeCategory20); + } + + for (let response of hazardResponses) { + let dataType = response.dataType; + + let responseData = response.find((responseData) => { + return responseData.metadata.imt.value == this.imtEl.value; + }); + + let data = responseData.data; + let metadata = responseData.metadata; + + let xValues = []; + let yValues = []; + + switch (dataType) { + case 'dynamic': + let componentData = data.find((d) => { return d.component == 'Total'; }); + xValues = metadata.xvalues; + yValues = componentData.yvalues; + break; + case 'static': + xValues = metadata.xvals; + yValues = data[0].yvals; + break; + default: + throw new NshmpError(`Response data type [${dataType}] not found`); + } + + let lineOptions = D3LineOptions.builder() + .id(metadata.edition.value) + .label(metadata.edition.display) + .build(); + + dataBuilder.data(xValues, yValues, lineOptions); + } + + return dataBuilder.build(); + } + +} diff --git a/webapp/apps/js/ModelExplorer.js b/webapp/apps/js/ModelExplorer.js new file mode 100644 index 000000000..c6239474a --- /dev/null +++ b/webapp/apps/js/ModelExplorer.js @@ -0,0 +1,394 @@ + +import { D3LineData } from './d3/data/D3LineData.js'; +import { D3LineLegendOptions } from './d3/options/D3LineLegendOptions.js'; +import { D3LineOptions } from './d3/options/D3LineOptions.js'; +import { D3LinePlot } from './d3/D3LinePlot.js'; +import { D3LineSeriesData } from './d3/data/D3LineSeriesData.js'; +import { D3LineSubViewOptions } from './d3/options/D3LineSubViewOptions.js'; +import { D3LineView } from './d3/view/D3LineView.js'; +import { D3LineViewOptions } from './d3/options/D3LineViewOptions.js'; + +import { Hazard } from './lib/Hazard.js'; +import NshmpError from './error/NshmpError.js'; + +export class ModelExplorer extends Hazard { + + constructor(config) { + super(config); + + this.header.setTitle("Model Explorer"); + + this.options = { + type: "explorer", + editionDefault: "E2014", + regionDefault: "COUS", + imtDefault: "PGA", + vs30Default: 760, + }; + + this.contentEl = document.querySelector("#content"); + this.hazardComponentPlotTitle = 'Hazard Component Curves'; + this.hazardPlotTitle = 'Hazard Curves'; + + /* Plot view options */ + this.viewOptions = D3LineViewOptions.builder() + .titleFontSize(14) + .viewSize('min') + .build(); + + /* Hazard curve plot setup */ + this.hazardView = this.setupHazardView(); + this.hazardView.updateViewSize('max'); + this.hazardLinePlot = new D3LinePlot(this.hazardView); + + /* Hazard component curves setup */ + this.hazardComponentView = this.setupHazardComponentView(); + this.hazardComponentView.hide(); + this.hazardComponentLinePlot = new D3LinePlot(this.hazardComponentView); + + let setParameters = (par) => { + this.parameters = par; + this.buildInputs(); + }; + + this.getHazardParameters(setParameters); + + $(this.footer.updateBtnEl).click(() => { + this.callHazard((result) => { this.callHazardCallback(result); }); + }); + } + + + /** + * Build the view for the hazard component curves plot. + * + * @returns {D3LineView} The hazard component line view + */ + setupHazardComponentView() { + /* Upper sub view legend options: hazard plot */ + let upperLegendOptions = D3LineLegendOptions.upperBuilder() + .location('bottom-left') + .build(); + + /* Upper sub view options: hazard plot */ + let upperSubViewOptions = D3LineSubViewOptions.upperBuilder() + .dragLineSnapTo(1e-10) + .filename('hazard-explorer-components') + .label('Hazard Component Curves') + .legendOptions(upperLegendOptions) + .lineLabel('IMT') + .xLabel('Ground Motion (g)') + .xAxisScale('log') + .yAxisScale('log') + .yLabel('Annual Frequency of Exceedence') + .yValueToExponent(true) + .build(); + + /* Build the view */ + let view = D3LineView.builder() + .containerEl(this.contentEl) + .viewOptions(this.viewOptions) + .upperSubViewOptions(upperSubViewOptions) + .build(); + + view.setTitle(this.hazardComponentPlotTitle); + + return view; + } + + /** + * Build the view for the hazard curve plot. + * + * @returns {D3LineView} The hazard line view + */ + setupHazardView() { + /* Upper sub view legend options: hazard plot */ + let upperLegendOptions = D3LineLegendOptions.upperBuilder() + .location('bottom-left') + .build(); + + /* Upper sub view options: hazard plot */ + let upperSubViewOptions = D3LineSubViewOptions.upperBuilder() + .dragLineSnapTo(1e-8) + .filename('hazard-explorer') + .label('Hazard Curves') + .lineLabel('IMT') + .legendOptions(upperLegendOptions) + .xAxisScale('log') + .xLabel('Ground Motion (g)') + .yAxisScale('log') + .yLabel('Annual Frequency of Exceedence') + .yValueToExponent(true) + .build(); + + /* Build the view */ + let view = D3LineView.builder() + .addLowerSubView(false) + .containerEl(this.contentEl) + .viewOptions(this.viewOptions) + .upperSubViewOptions(upperSubViewOptions) + .build(); + + view.setTitle(this.hazardPlotTitle); + + return view; + } + + buildInputs() { + this.spinner.off(); + + let editionValues = this.parameters.edition.values; + this.setParameterMenu("edition", editionValues); + + let supportedRegions = this.supportedRegions(); + this.setParameterMenu("region", supportedRegions); + this.setBounds(); + + let supportedImt = this.supportedValues("imt") + let supportedVs30 = this.supportedValues("vs30") + this.setParameterMenu("imt", supportedImt); + this.setParameterMenu("vs30", supportedVs30); + + $(this.editionEl).change(() => { + this.resetPlots(); + this.clearCoordinates(); + supportedRegions = this.supportedRegions(); + this.setParameterMenu("region", supportedRegions); + this.setBounds(); + supportedImt = this.supportedValues("imt") + supportedVs30 = this.supportedValues("vs30") + this.setParameterMenu("imt", supportedImt); + this.setParameterMenu("vs30", supportedVs30); + this.testSitePicker.checkForRegion(this.region()); + }); + + $(this.regionEl).change(() => { + this.resetPlots(); + this.clearCoordinates(); + this.setBounds(); + supportedImt = this.supportedValues("imt") + supportedVs30 = this.supportedValues("vs30") + this.setParameterMenu("imt", supportedImt); + this.setParameterMenu("vs30", supportedVs30); + this.testSitePicker.checkForRegion(this.region()); + }); + + $(this.controlEl).removeClass('hidden'); + + this.testSitePicker.on('testSiteLoad', (event) => { + let urlInfo = this.checkQuery(); + if (urlInfo) this.callHazard((result) => { this.callHazardCallback(result); }); + }); + } + + resetPlots() { + this.hazardLinePlot.clearAll(); + this.hazardComponentLinePlot.clearAll(); + this.hazardView.setTitle(this.hazardPlotTitle); + this.hazardComponentView.setTitle(this.hazardComponentPlotTitle); + } + + /** + * Get the metadata + * @return {Map>} The metadata Map + */ + getMetadata() { + let metadata = new Map(); + metadata.set('Edition:', [$(this.editionEl).find(':selected').text()]); + metadata.set('Region:', [$(this.regionEl).find(':selected').text()]); + metadata.set('Latitude (°):', [this.latEl.value]); + metadata.set('Longitude (°):', [this.lonEl.value]); + metadata.set('Intensity Measure Type:', [$(this.imtEl).find(':selected').text()]); + metadata.set('VS30:', [$(this.vs30El).find(':selected').text()]); + + return metadata; + } + + supportedRegions() { + let selectedEdition = this.parameters.edition + .values.find((edition, i) => { + return edition.value == this.editionEl.value; + }); + + let supportedRegions = this.parameters.region.values.filter((region, ir) => { + return selectedEdition.supports.region.find((regionVal, irv) => { + return regionVal == region.value; + }) + }); + + return supportedRegions; + } + + callHazardCallback(hazardReturn) { + this.plotHazardCurves(hazardReturn); + } + + /** + * + * @param {D3LineView} view + */ + updatePlotTitle(view) { + let imt = $(':selected', this.imtEl).text(); + let vs30 = $(':selected', this.vs30El).text(); + let siteTitle = this.testSitePicker.getTestSiteTitle(this.region()); + let title = `${siteTitle}, ${imt}, ${vs30}`; + + view.setTitle(title); + } + + onIMTChange(response) { + this.hazardLinePlot.selectLine( + this.imtEl.value, + this.hazardResponseToLineData(response)); + + this.updatePlotTitle(this.hazardView); + if (response.dataType == 'dynamic') { + this.plotComponentCurves(response); + } + } + + plotHazardCurves(responses) { + this.spinner.off(); + this.hazardLinePlot.clearAll(); + let response = responses[0]; + + let dataType = response.dataType; + + let lineData = this.hazardResponseToLineData(response); + this.hazardLinePlot.plot(lineData); + this.hazardLinePlot.selectLine(this.imtEl.value, lineData); + this.updatePlotTitle(this.hazardView); + + $(this.imtEl).off(); + + $(this.imtEl).change(() => { + this.onIMTChange(response); + }); + + this.hazardLinePlot.onPlotSelection( + lineData, + (/** @type {D3LineSeriesData} */ dataSeries) => { + this.onIMTSelection(dataSeries, response); + }); + + switch (dataType) { + case 'dynamic': + this.hazardView.updateViewSize('min'); + this.hazardComponentView.show(); + this.hazardComponentView.updateViewSize('min'); + this.plotComponentCurves(response); + break; + case 'static': + this.hazardView.updateViewSize('max'); + this.hazardComponentView.hide(); + break; + default: + throw new NshmpError(`Response data type [${dataType}] not found`); + } + + let metadata = this.getMetadata(); + this.hazardView.setMetadata(metadata); + this.hazardView.createMetadataTable(); + + this.hazardView.setSaveData(lineData); + this.hazardView.createDataTable(lineData); + } + + /** + * + * @param {D3LineSeriesData} dataSeries + */ + onIMTSelection(dataSeries, hazardResponse) { + this.imtEl.value = dataSeries.lineOptions.id; + this.updatePlotTitle(this.hazardView); + + if (hazardResponse.dataType == 'dynamic') { + this.plotComponentCurves(hazardResponse); + } + } + + hazardResponseToLineData(response) { + let dataBuilder = D3LineData.builder() + .subView(this.hazardView.upperSubView) + .removeSmallValues(this.Y_MIN_CUTOFF); + + if (response.length > 10) { + dataBuilder.colorScheme(d3.schemeCategory20); + } + + let dataType = response.dataType; + + for (let responseData of response) { + let data = responseData.data; + let xValues = []; + let yValues = []; + + switch (dataType) { + case 'dynamic': + let componentData = data.find((d) => { return d.component == 'Total'; }); + xValues = responseData.metadata.xvalues; + yValues = componentData.yvalues; + break; + case 'static': + xValues = responseData.metadata.xvals; + yValues = data[0].yvals; + break; + default: + throw new NshmpError(`Response data type [${dataType}] not found`); + } + + let lineOptions = D3LineOptions.builder() + .id(responseData.metadata.imt.value) + .label(responseData.metadata.imt.display) + .build(); + + dataBuilder.data(xValues, yValues, lineOptions); + } + + return dataBuilder.build(); + } + + plotComponentCurves(response) { + this.hazardComponentLinePlot.clearAll(); + let lineData = this.hazardResponseToComponentLineData(response); + this.hazardComponentLinePlot.plot(lineData); + this.updatePlotTitle(this.hazardComponentView); + + let metadata = this.getMetadata(); + this.hazardComponentView.setMetadata(metadata); + this.hazardComponentView.createMetadataTable(); + + this.hazardComponentView.setSaveData(lineData); + this.hazardComponentView.createDataTable(lineData); + } + + hazardResponseToComponentLineData(hazardResponse) { + let response = hazardResponse.find((response) => { + return response.metadata.imt.value == this.imtEl.value; + }); + + let metadata = response.metadata; + + let components = response.data.filter((data) => { + return data.component != 'Total'; + }); + + let xValues = metadata.xvalues; + + let dataBuilder = D3LineData.builder() + .subView(this.hazardComponentView.upperSubView) + .removeSmallValues(this.Y_MIN_CUTOFF); + + for (let componentData of components) { + let lineOptions = D3LineOptions.builder() + .id(componentData.component) + .label(componentData.component) + .build(); + + dataBuilder.data(xValues, componentData.yvalues, lineOptions); + } + + return dataBuilder.build(); + } + +} diff --git a/webapp/apps/js/Services.js b/webapp/apps/js/Services.js new file mode 100644 index 000000000..2332da797 --- /dev/null +++ b/webapp/apps/js/Services.js @@ -0,0 +1,500 @@ +'use strict'; + +import Header from './lib/Header.js'; + +/** +* @fileoverview Set all service information. +* +* @class Services +* @author bclayton@usgs.gov (Brandon Clayton) +*/ +export default class Services { + + constructor(config) { + /** @type {Header} */ + this.header = new Header(); + this.header.setTitle('Services'); + + /** @type {String} */ + this.urlPrefix = config.server.dynamic.trim() != '' ? + config.server.dynamic + '/nshmp-haz-ws' : + window.location.protocol + '//' + + window.location.host + '/nshmp-haz-ws'; + + /** @type {HTMLElement} */ + this.servicesEl = undefined; + /** @type {HTMLElement} */ + this.serviceMenuEl = undefined; + /** @type {HTMLElement} */ + this.menuListEl = undefined; + + // Create services + this.createOutline(); + this.hazardService(); + this.deaggService(); + this.rateService(); + this.probabilityService(); + this.spectraService(); + this.gmmDistanceService(); + this.hwFwService(); + + //this.formatUrl(); + this.backToTop() + } + + /** + * @method backToTop + * + * Create an anchor for going back to the top of the page + */ + backToTop() { + d3.select(this.menuListEl) + .append('li') + .attr('class', 'back-to-top') + .append('a') + .attr('href', '#top') + .text('Back to top'); + } + + /** + * @method createOutline + * + * Create the outline of the web page + */ + createOutline() { + let containerD3 = d3.select('body') + .append('div') + .attr('class', 'container content') + .attr('id', 'top'); + + let rowD3 = containerD3.append('div') + .attr('class', 'row'); + + let servicesD3 = rowD3.append('div') + .attr('class', 'col-sm-9 services') + .attr('role', 'main'); + + let serviceMenuD3 = rowD3.append('div') + .attr('class', 'col-sm-3 service-menu') + .attr('id', 'service-menu') + .attr('role', 'complimentary') + .append('div') + .attr('class', 'affix hidden-xs'); + + let menuListD3 = serviceMenuD3.append('ul') + .attr('class', 'nav service-menu-list'); + + containerD3.lower(); + d3.select(this.header.headerEl).lower(); + this.servicesEl = servicesD3.node(); + this.serviceMenuEl = serviceMenuD3.node(); + this.menuListEl = menuListD3.node(); + } + + /** + * @method deaggService + * + * Create the service information for deagg + */ + deaggService() { + let svc = {}; + svc.name = 'Deaggregation'; + svc.service = 'deagg'; + svc.id = 'deagg'; + svc.usage = '/nshmp-haz-ws/deagg'; + + svc.description = 'Deaggregate seismic hazard.'; + svc.formats = [ + '/deagg/edition/region/longitude/latitude/imt/vs30/returnperiod', + '/deagg?edition=value®ion=value&longitude=value&' + + 'latitude=value&imt=value&vs30=value&returnperiod=value', + ]; + svc.parameters = [ + 'edition [E2008, E2014]', + 'region [COUS, WUS, CEUS]', + 'longitude (-360..360) °', + 'latitude [-90..90] °', + 'imt (intensity measure type) [PGA, SA0P2, SA1P0]', + 'vs30 [180, 259, 360, 537, 760, 1150, 2000] m/s', + 'returnperiod [1..4000] years', + ]; + svc.examples = [ + '/deagg/E2008/WUS/-118.25/34.05/PGA/760/2475', + '/deagg?edition=E2008®ion=WUS&longitude=-118.25&' + + 'latitude=34.05&imt=PGA&vs30=760&returnperiod=2475', + ]; + svc.info = 'The deaggregation service only supports calculations for' + + ' a single IMT, which must be specified'; + + this.makeService(svc); + } + + /** + * @method gmmDistanceService + * + * Create the service information for gmm distance + */ + gmmDistanceService() { + let svc = {}; + svc.name = 'Ground Motion Vs. Distance'; + svc.service = 'gmm/distance'; + svc.id = 'gmm-distance'; + svc.usage = '/nshmp-haz-ws/gmm/distance'; + + svc.description = 'Compute ground motion Vs. distance.' + svc.formats = [ + '/gmm/distance?gmm=value&Mw=value&imt=value&dip=value&' + + 'width=value&ztop=value&vs30=value&vsinf=boolean&' + + 'rMin=value&rMax=value&z2p5=value&z1p0=value' + ]; + svc.parameters = [ + 'gmm [AS_97, ZHAO_16_UPPER_MANTLE]', + 'Mw [-2, 9.7]', + 'imt (intensity measure type) [PGA, SA10P0]', + 'dip [0, 90] °', + 'width [0, 60] km', + 'ztop [0, 700] km', + 'vs30 [150, 2000] m/s', + 'vsinf boolean', + 'rMin 0.001 km', + 'rMax 300 km', + 'z1p0 [0, 5] km', + 'z2p5 [0, 10] km', + ]; + svc.examples = [ + '/gmm/distance?gmm=AB_06_PRIME&gmm=CAMPBELL_03&gmm=FRANKEL_96&' + + 'imt=PGA&Mw=6.5&zTop=0.5&dip=90&width=14&vs30=760&' + + 'vsInf=true&z1p0=&z2p5=&rMin=0.001&rMax=300' + ]; + svc.info = 'Not all parameters are used by every ground motion' + + ' model (gmm). At least one "gmm" must be specified. Default' + + ' values will be used for any parameters not specified.'; + + this.makeService(svc); + } + + /** + * @method hazardService + * + * Create the service information for hazard + */ + hazardService() { + let svc = {}; + svc.name = 'Hazard'; + svc.service = 'hazard'; + svc.id = 'hazard'; + svc.usage = '/nshmp-haz-ws/hazard'; + + svc.description = 'Compute probabilisitic seismic hazard ' + + 'curves at a site of interest.'; + svc.formats = [ + '/hazard/edition/region/longitude/latitude/imt/vs30', + '/hazard?edition=value®ion=value&longitude=value&' + + 'latitude=value&imt=value&vs30=value', + ]; + svc.parameters = [ + 'edition [E2008, E2014]', + 'region [COUS, WUS, CEUS]', + 'longitude (-360..360) °', + 'latitude [-90..90] °', + 'imt (intensity measure type) [PGA, SA0P2, SA1P0]', + 'vs30 [180, 259, 360, 537, 760, 1150, 2000] m/s', + ]; + svc.examples = [ + '/hazard/E2008/WUS/-118.25/34.05/PGA/760', + '/hazard?edition=E2008®ion=WUS&longitude=-118.25&' + + 'latitude=34.05&imt=PGA&vs30=760', + ]; + svc.info = 'For the slash delimited format, multiple, comma-delimited' + + ' IMTs may be supplied. Alternatively, "any" may be supplied as' + + ' the IMT id and the service will return curves for all supported' + + ' IMTs. For the name-value pair format, one may use multiple IMT' + + ' name-value pairs, or omit the IMT argument altogether to return' + + ' curves for all supported IMTs' + + this.makeService(svc); + } + + /** + * @method gmmDistanceService + * + * Create the service information for hanging wall effects + */ + hwFwService() { + let svc = {}; + svc.name = 'Hanging Wall Effect'; + svc.service = 'gmm/hw-fw'; + svc.id = 'hw-fw'; + svc.usage = '/nshmp-haz-ws/gmm/hw-fw'; + + svc.description = 'Compute ground motion Vs. distance.' + svc.formats = [ + '/gmm/hw-fw?gmm=value&Mw=value&imt=value&dip=value&' + + 'width=value&ztop=value&vs30=value&vsinf=boolean&' + + 'rMin=value&rMax=value&z2p5=value&z1p0=value' + ]; + svc.parameters = [ + 'gmm [AS_97, ZHAO_16_UPPER_MANTLE]', + 'Mw [-2, 9.7]', + 'imt (intensity measure type) [PGA, SA10P0]', + 'dip [0, 90] °', + 'width [0, 60] km', + 'ztop [0, 700] km', + 'vs30 [150, 2000] m/s', + 'vsinf boolean ', + 'rMin -20 km', + 'rMax 70 km', + 'z1p0 [0, 5] km', + 'z2p5 [0, 10] km', + ]; + svc.examples = [ + '/gmm/hw-fw?gmm=AB_06_PRIME&gmm=CAMPBELL_03&gmm=FRANKEL_96&' + + 'imt=PGA&Mw=6.5&zTop=0.5&dip=90&width=14&vs30=760&' + + 'vsInf=true&z1p0=&z2p5=&rMin=-20&rMax=70' + ]; + svc.info = 'Not all parameters are used by every ground motion' + + ' model (gmm). At least one "gmm" must be specified. Default' + + ' values will be used for any parameters not specified.'; + + this.makeService(svc); + } + + /** + * @method makeService + * + * Create a panel with each service's information + * @param {{ + * name: {String} - Name of service, + * service: {String} - Service url, + * id: {String} - an id, + * usage: {String} - url, + * description: {String} - Description of service, + * formats: {Array} - Service url format, + * parameters: {Array} - Parameters used in the url, + * examples: {Array} - Example urls, + * info: {String} - Any additional info abput service, + * }} svc - Service object + */ + makeService(svc) { + this.serviceMenu(svc); + + let panelD3 = d3.select(this.servicesEl) + .append('div') + .attr('class', 'service') + .attr('id', svc.id) + .append('div') + .attr('class', 'panel panel-default'); + + // Service name + panelD3.append('div') + .attr('class', 'panel-heading') + .append('h3') + .text(svc.service); + + let serviceD3 = panelD3.append('div') + .attr('class', 'panel-body'); + + // Service description + serviceD3.append('div') + .attr('class', 'service-div') + .text(svc.description); + + // Format list + let formatD3 = serviceD3.append('div') + .attr('class', 'service-div'); + formatD3.append('h4') + .text('Formats'); + formatD3.append('ul') + .selectAll('li') + .data(svc.formats) + .enter() + .append('li') + .attr('class', 'format-url list-group-item') + .text((d, i) => { return this.urlPrefix + d }); + + // Parameter list + let parD3 = serviceD3.append('div') + .attr('class', 'service-div'); + parD3.append('h4') + .text('Parameters'); + parD3.append('ul') + .selectAll('li') + .data(svc.parameters) + .enter() + .append('li') + .attr('class', 'list-group-item') + .html((d, i) => {return d}); + + // Additional info + serviceD3.append('div') + .attr('class', 'service-div') + .html(svc.info); + + // Usage URL + serviceD3.append('div') + .attr('class', 'service-div') + .html('See' + ' usage ' + + 'for parameter dependencies'); + + // Examples + let exD3 = panelD3.append('div') + .attr('class', 'panel-footer'); + exD3.append('h4') + .attr('class', 'examples') + .text('Examples'); + exD3.selectAll('div') + .data(svc.examples) + .enter() + .append('div') + .attr('class', 'service-link') + .append('a') + .attr('href', (d,i) => {return this.urlPrefix + d}) + .text((d, i) => {return this.urlPrefix + d}); + } + + /** + * @method probabilityService + * + * Create the service information for probability + */ + probabilityService() { + let svc = {}; + svc.name = 'Probability'; + svc.service = 'probability'; + svc.id = 'probability'; + svc.usage = '/nshmp-haz-ws/probability'; + + svc.description = 'Compute the Poisson probability of earthquake' + + ' occurrence at a site of interest.'; + svc.formats = [ + '/probability/edition/region/longitude/latitude/distance', + '/probability?edition=value®ion=value&longitude=value&' + + 'latitude=value&distance=value×pan=value', + ]; + svc.parameters = [ + 'edition [E2008, E2014]', + 'region [COUS, WUS, CEUS]', + 'longitude (-360..360) °', + 'latitude [-90..90] °', + 'distance [0.01..1000] km', + 'timespan [1..10000] years', + ]; + svc.examples = [ + '/probability/E2008/WUS/-118.25/34.05/20/50', + '/probability?edition=E2008®ion=WUS&longitude=-118.25' + + '&latitude=34.05&distance=20×pan=50', + ]; + svc.info = ''; + + this.makeService(svc); + } + + /** + * @method rateService + * + * Create the service information for rate + */ + rateService() { + let svc = {}; + svc.name = 'Rate'; + svc.service = 'rate'; + svc.id = 'rate'; + svc.usage = '/nshmp-haz-ws/rate'; + + svc.description = 'Compute the annual rate of earthquakes at a site' + + ' of interest.'; + svc.formats = [ + '/rate/edition/region/longitude/latitude/distance', + '/rate?edition=value®ion=value&longitude=value&' + + 'latitude=value&distance=value', + ]; + svc.parameters = [ + 'edition [E2008, E2014]', + 'region [COUS, WUS, CEUS]', + 'longitude (-360..360) °', + 'latitude [-90..90] °', + 'distance [0.01..1000] km', + ]; + svc.examples = [ + '/rate/E2008/WUS/-118.25/34.05/20', + '/rate?edition=E2008®ion=WUS&longitude=-118.25&' + + 'latitude=34.05&distance=20', + ]; + svc.info = ''; + + this.makeService(svc); + } + + /** + * @method serviceMenu + * + * Add onto the service menu + * @param {{ + * name: {String} - Name of service, + * service: {String} - Service url, + * id: {String} - an id, + * usage: {String} - url, + * description: {String} - Description of service, + * formats: {Array} - Service url format, + * parameters: {Array} - Parameters used in the url, + * examples: {Array} - Example urls, + * info: {String} - Any additional info abput service, + * }} svc - Service object + */ + serviceMenu(svc) { + d3.select(this.menuListEl).append('li') + .append('a') + .attr('href', '#' + svc.id) + .text(svc.name); + } + + /** + * @method spectraService + * + * Create the service information for response spectra + */ + spectraService() { + let svc = {}; + svc.name = 'Response Spectra'; + svc.service = 'gmm/spectra'; + svc.id = 'spectra'; + svc.usage = '/nshmp-haz-ws/gmm/spectra'; + + svc.description = 'Compute determinisitic reponse spectra.' + svc.formats = [ + '/gmm/spectra?gmm=value&Mw=value&rjb=value&rrup=value&' + + 'rx=value&dip=value&width=value&ztop=value&' + + 'zhyp=value&rake=value&vs30=value&vsinf=boolean&' + + 'z2p5=value&z1p0=value' + ]; + svc.parameters = [ + 'gmm [AS_97, ZHAO_16_UPPER_MANTLE]', + 'Mw [-2, 9.7]', + 'rjb [0, 1000] km', + 'rrup [0, 1000] km', + 'rx [0, 1000] km', + 'dip [0, 90] °', + 'width [0, 60] km', + 'ztop [0, 700] km', + 'zhyp [0, 700] km', + 'rake [-180, 180] °', + 'vs30 [150, 2000] m/s', + 'vsinf boolean ', + 'z1p0 [0, 5] km', + 'z2p5 [0, 10] km', + ]; + svc.examples = [ + '/gmm/spectra?gmm=AB_06_PRIME&gmm=CAMPBELL_03&gmm=FRANKEL_96&' + + 'mw=8.7&rjb=10.0&rrup=23.0&' + + 'rx=32.0&dip=30.0&width=25.0&ztop=10.0&' + + 'zhyp=20.0&rake=90.0&vs30=760.0&vsinf=true&' + + 'z2p5=NaN&z1p0=NaN' + ]; + svc.info = 'Not all parameters are used by every ground motion' + + ' model (gmm). At least one "gmm" must be specified. Default' + + ' values will be used for any parameters not specified.'; + + this.makeService(svc); + } + +} diff --git a/webapp/apps/js/Spectra.js b/webapp/apps/js/Spectra.js new file mode 100644 index 000000000..4be24c98c --- /dev/null +++ b/webapp/apps/js/Spectra.js @@ -0,0 +1,1156 @@ + +import { D3LineData } from './d3/data/D3LineData.js'; +import { D3LineOptions } from './d3/options/D3LineOptions.js'; +import { D3LinePlot } from './d3/D3LinePlot.js'; +import { D3LineSubViewOptions } from './d3/options/D3LineSubViewOptions.js'; +import { D3LineView } from './d3/view/D3LineView.js'; +import { D3LineViewOptions } from './d3/options/D3LineViewOptions.js'; +import { D3SaveFigureOptions } from './d3/options/D3SaveFigureOptions.js'; + +import { GmmBeta } from './lib/GmmBeta.js'; +import Tools from './lib/Tools.js'; +import NshmpError from './error/NshmpError.js'; +import { Preconditions } from './error/Preconditions.js'; + +/** + * @fileoverview Class for spectra-plot.html, response spectra web app. + * This class plots the results of nshmp-haz-ws/gmm/spectra web service. + * This class will first call out to nshmp-haz-ws/gmm/spectra web service + * to obtain the usage and create the control panel with the following: + * - Ground motions models + * - Magnitude + * - Rake + * - zHyp + * - Fault mech (strike-slip, normal, reverse) + * - zTop + * - Dip + * - Width + * - rX + * - rRup + * - rJB + * - Vs30 + * - Vs30 measured or inferred + * - Z1.0 + * - Z2.5 + * Once the control panel is set, it can be used to select desired + * parameters and plot ground motion vs. period. + * + * @class Spectra + * @extends GmmBeta + * @author bclayton@usgs.gov (Brandon Clayton) + */ +export class Spectra extends GmmBeta { + + constructor(config) { + let webApp = 'Spectra'; + let wsUrl = '/nshmp-haz-ws/gmm/spectra' + + super(webApp, wsUrl, config); + + this.header.setTitle('Response Spectra'); + + /** The main content element for plots - @type {HTMLElement} */ + this.contentEl = document.querySelector('#content'); + + /* Magnitude buttons */ + this.MwBtns = [ + { text: '4.0', value: 4.0 }, + { text: '4.5', value: 4.5 }, + { text: '5.0', value: 5.0 }, + { text: '5.5', value: 5.5 }, + { text: '6.0', value: 6.0 }, + { text: '6.5', value: 6.5 }, + { text: '7.0', value: 7.0 }, + { text: '7.5', value: 7.5 }, + { text: '8.0', value: 8.0 }, + { text: '8.5', value: 8.5 }, + ]; + + /* Rake Buttons */ + this.rakeBtns = [ + { text: 'Strike-Slip', id: 'fault-style-strike', value: 0.0 }, + { text: 'Normal', id: 'fault-style-normal', value: -90 }, + { text: 'Reverse', id: 'fault-style-reverse', value: 90 }, + ]; + + /* Hanging wall - foot wall */ + this.hwFwBtns = [ + { text: 'Hanging Wall', id: 'hw-fw-hw', value: 'hw', isActive: true }, + { text: 'Foot Wall', id: 'hw-fw-fw', value: 'fw'}, + ]; + + /* Vs30 buttons */ + this.vs30Btns = [ + { text: '150', value: 150.0 }, + { text: '185', value: 185.0 }, + { text: '260', value: 260.0 }, + { text: '365', value: 365.0 }, + { text: '530', value: 530.0 }, + { text: '760', value: 760.0 }, + { text: '1080', value: 1080.0 }, + { text: '2000', value: 2000.0 }, + { text: '3000', value: 3000.0 }, + ]; + + /* What parameters are multi-selectable */ + this.multiSelectValues = [ + { text: 'Ground Motion Models', value: 'gmms' }, + { text: 'Mw', value: 'Mw' }, + { text: 'Vs30', value: 'vs30' }, + ]; + + /** X-Axis domain for spectra plots - @type {Array} */ + this.spectraXDomain = [0.01, 10.0]; + + /* Get the usage and create control panel */ + this.getUsage(); + + this.spectraView = this.setupSpectraView(); + this.spectraLinePlot = new D3LinePlot(this.spectraView); + + this.pga = 0.01; + } + + /** + * Create the control panel with a: + * - Multi-selectable select menu + * - GMM select menu + * - Event parameters: + * - Mw + * - Rake + * - zHyp + * - Source parameters: + * - zTop + * - Dip + * - Width + * - Path parameters: + * - rX + * - rRup + * - rJB + * - Site parameters: + * - Vs30 + * - Z1p0 + * - Z2p5 + */ + createControlPanel() { + this.spinner.off(); + + /** Multi-selectable select menu - @type {HTMLElement} */ + this.multiSelectEl = this._createMultiSelect(); + + /* Create the GMM sorter and select menu */ + let gmmEls = this.controlPanel.createGmmSelect(this.parameters); + /** GMM select menu - @type {HTMLElement} */ + this.gmmsEl = gmmEls.gmmsEl; + /** GMM group sorter element - @type {HTMLElement} */ + this.gmmGroupEl = gmmEls.gmmGroupEl; + /** GMM alpha sorter element - @type {HTMLElement} */ + this.gmmAlphaEl = gmmEls.gmmAlphaEl; + /** GMM alpha select option elements - @type {Array} */ + this.gmmAlphaOptions = gmmEls.gmmAlphaOptions; + /** GMM group select option elements - @type {Array} */ + this.gmmGroupOptions = gmmEls.gmmGroupOptions; + + this._createEventParameters(this.parameters); + this._createSourceParameters(this.parameters); + this._createPathParameters(this.parameters); + this._createSiteParameters(this.parameters); + + /* Add event listeners */ + this._listeners(); + + /* Check URL query */ + this.checkQuery(); + } + + /** + * Get metadata about all chosen parameters. + * + * @return {Map} + * The metadata with all chosen parameters. + */ + getMetadata() { + let gmms = this.getCurrentGmms(); + + let metadata = new Map(); + metadata.set('Ground Motion Model:', gmms); + metadata.set('MW:', this.getValues(this.MwEl)); + metadata.set('Rake (°):', this.getValues(this.rakeEl)); + metadata.set('ZTop (km):', this.getValues(this.zTopEl)); + metadata.set('Dip (°):', this.getValues(this.dipEl)); + metadata.set('Width (km):', this.getValues(this.widthEl)); + metadata.set('RX (km):', this.getValues(this.rXEl)); + metadata.set('RRup (km):', this.getValues(this.rRupEl)); + metadata.set('RJB (km):', this.getValues(this.rJBEl)); + metadata.set('Vs30 (m/s):', this.getValues(this.vs30El)); + metadata.set('Z1.0 (km):', this.getValues(this.z1p0El)); + metadata.set('Z2.5 (km):', this.getValues(this.z2p5El)); + + return metadata; + } + + /** + * Find the value(s) of a particular element. + * + * @param {HTMLElement} el The parameter to get chosen values from. + * @returns {Array | Number} The parameter values. + */ + getValues(el) { + let multiSelectParam = this.multiSelectEl.value; + let btnGroupEl = d3.select(this.multiSelectEl).data()[0]; + + if (multiSelectParam == el.id) { + let tmpValues = $(':checked', btnGroupEl).map((i, d) => { + return parseFloat(d.value); + }).get(); + + let nValues = tmpValues.length; + let maxValues = 3; + let nLoops = Math.ceil( nValues / maxValues ); + let values = []; + + let iStart = 0; + let iEnd = 0; + for (let i = 0; i < nLoops; i++) { + iStart = iEnd; + iEnd = iStart + maxValues; + + values.push(tmpValues.slice(iStart, iEnd).join(', ')); + if (i < nLoops - 1) values[i] += ','; + } + + return values; + } else { + return [parseFloat(el.value)]; + } + } + + /** + * Plot spectra results. + * + * @param {Array} responses + */ + plotSpectra(responses) { + this.spectraLinePlot.clearAll(); + let metadata = this.getMetadata(); + let means = this.plotSpectraMeans(responses); + let sigmas = this.plotSpectraSigma(responses); + this.spectraLinePlot.syncSubViews(); + + this.spectraView.setMetadata(metadata); + this.spectraView.createMetadataTable(); + + let meanData = means.pgaData.concat(means.lineData); + let sigmaData = sigmas.pgaData.concat(sigmas.lineData); + this.spectraView.setSaveData(meanData, sigmaData); + this.spectraView.createDataTable(meanData, sigmaData); + } + + /** + * Plot the spectra means. + * + * @param {Array} responses + */ + plotSpectraMeans(responses) { + let data = this._responsesToLineData( + responses, + this.spectraView.upperSubView, + 'means'); + + let lineData = data.lineData; + let pgaData = data.pgaData; + + this.spectraLinePlot.plot(lineData); + this.spectraLinePlot.plot(pgaData); + + return data; + } + + /** + * Plot the spectra sigmas + * @param {Array} responses + */ + plotSpectraSigma(responses) { + let data = this._responsesToLineData( + responses, + this.spectraView.lowerSubView, + 'sigmas'); + + let lineData = data.lineData; + let pgaData = data.pgaData; + + this.spectraLinePlot.plot(lineData); + this.spectraLinePlot.plot(pgaData); + + return data; + } + + /** + * Setup the plot view + */ + setupSpectraView() { + /* Save figure options */ + let saveOptions = D3SaveFigureOptions.builder() + .metadataColumns(4) + .build(); + + /* Upper sub view options: means */ + let upperSubViewOptions = D3LineSubViewOptions.upperBuilder() + .filename('spectra-means') + .label('Response Spectra Means') + .lineLabel('Ground Motion Model') + .saveFigureOptions(saveOptions) + .xLabel('Period (s)') + .yAxisScale('linear') + .yLabel('Median Ground Motion (g)') + .build(); + + /* Lower sub view options: sigmas */ + let lowerSubViewOptions = D3LineSubViewOptions.lowerBuilder() + .filename('spectra-sigmas') + .label('Response Spectra Sigmas') + .lineLabel('Ground Motion Model') + .saveFigureOptions(saveOptions) + .showLegend(false) + .xLabel('Period (s)') + .yAxisScale('linear') + .yLabel('Standard Deviation') + .build(); + + let viewOptions = D3LineViewOptions.builder() + .syncXAxisScale(true, 'log') + .syncYAxisScale(false) + .viewSize('max') + .build(); + + let view = D3LineView.builder() + .addLowerSubView(true) + .containerEl(this.contentEl) + .viewOptions(viewOptions) + .lowerSubViewOptions(lowerSubViewOptions) + .upperSubViewOptions(upperSubViewOptions) + .build(); + + view.setTitle('Response Spectra'); + + return view; + } + + /** + * Call the ground motion web service and plot the results + */ + updatePlot() { + let urls = this.serializeGmmUrl(); + let jsonCall = Tools.getJSONs(urls); + + this.spinner.on(jsonCall.reject, 'Calculating'); + + Promise.all(jsonCall.promises).then((responses) => { + this.spinner.off(); + // NshmpError.checkResponses(responses, this.plot); + + this.footer.setMetadata(responses[0].server); + + this.plotSpectra(responses); + + $(this.footer.rawBtnEl).off(); + $(this.footer.rawBtnEl).click((event) =>{ + for (let url of urls) { + window.open(url); + } + }); + }).catch((errorMessage) => { + this.spinner.off(); + this.spectraLinePlot.clearAll(); + NshmpError.throwError(errorMessage); + }); + + } + + /** + * Create a form group for dip with a: + * - Input form + * - Slider + * + * @param {Object} params The spectra JSON usage. + */ + _createDipFormGroup(params) { + let inputOptions = { + id: 'dip', + label: 'Dip', + labelColSize: 'col-xs-2', + max: params.dip.max, + min: params.dip.min, + name: 'dip', + value: params.dip.value, + }; + + let sliderOptions = { + id: 'dip-slider', + } + + let dipEls = this.controlPanel.formGroupBuilder() + .addInput(inputOptions) + .addInputAddon('°') + .addInputTooltip() + .addInputSlider(sliderOptions) + .syncValues() + .build(); + + this.dipEl = dipEls.inputEl; + this.dipSliderEl = dipEls.sliderEl; + } + + /** + * Create all event parameters: + * - Magnitude (input form, slider, buttons) + * - Rake (input form, slider, buttons) + * - zHyp (input, checkbox) + * + * @param {Object} params The spectra JSON usage. + */ + _createEventParameters(params) { + this.controlPanel.createLabel({ + appendTo: this.controlPanel.formHorizontalEl, + label: 'Event Parameters:'}); + + /* Magnitude form group*/ + this._createMagnitudeFormGroup(params); + /* Rake form group */ + this._createRakeFormGroup(params); + /* zHyp form group */ + this._createZHypFormGroup(params); + } + + /** + * Create a form group for magnitude with a: + * - Input form + * - Slider + * - Buttons + * + * @param {Object} params The spectra JSON usage. + */ + _createMagnitudeFormGroup(params) { + let inputOptions = { + id: 'Mw', + label: 'Mw', + labelColSize: 'col-xs-2', + max: params.Mw.max, + min: params.Mw.min, + name: 'Mw', + step: 0.1, + value: params.Mw.value, + }; + + let btnOptions = { + addLabel: false, + id: 'Mw-btn-group', + name: 'Mw', + }; + + let sliderOptions = { + id: 'Mw-slider', + }; + + this.MwEls = this.controlPanel.formGroupBuilder() + .addInput(inputOptions) + .addInputSlider(sliderOptions) + .addBtnGroup(this.MwBtns, btnOptions) + .syncValues() + .addInputTooltip() + .build(); + + this.MwEl = this.MwEls.inputEl; + this.MwBtnGroupEl = this.MwEls.btnGroupEl; + this.MwSliderEl = this.MwEls.sliderEl; + } + + /** + * Create the select menu with the multi-selectable options. + */ + _createMultiSelect() { + let selectOptions = { + id: 'multiple-select', + label: 'Multi Value Parameter:', + labelControl: false, + value: 'gmms', + }; + + let optionArray = this.controlPanel + .toSelectOptionArray(this.multiSelectValues); + + let multiSelectEls = this.controlPanel.formGroupBuilder() + .addSelect(optionArray, selectOptions) + .build(); + + return multiSelectEls.selectEl; + } + + /** + * Create the path parameters: + * - rX (input form, slider) + * - rRup (input form, checkbox) + * - rJB (input form, buttons) + * + * @param {Object} params The spectra JSON usage. + */ + _createPathParameters(params) { + this.controlPanel.createLabel({ + appendTo: this.controlPanel.formHorizontalEl, + label: 'Path Parameters:'}); + + /* rX form group */ + this._createRXFormGroup(params); + // TODO Implement rY parameters + // /* rY form group */ + // this._createRYFormGroup(params); + /* rRup form group */ + this._createRRupFormGroup(params); + /* rJB form group */ + this._createRJBFormGroup(params); + } + + /** + * Create a form group for rake with a: + * - Input form + * - Buttons + * + * @param {Object} params The spectra JSON usage. + */ + _createRakeFormGroup(params) { + let inputOptions = { + id: 'rake', + label: 'Rake', + labelColSize: 'col-xs-2', + max: params.rake.max, + min: params.rake.min, + name: 'rake', + value: params.rake.value, + }; + + let btnOptions = { + addLabel: false, + id: 'fault-style', + name: 'rake', + }; + + let sliderOptions = { + id: 'rake-slider', + }; + + this.rakeEls = this.controlPanel.formGroupBuilder() + .addInput(inputOptions) + .addInputAddon('°') + .addInputSlider(sliderOptions) + .addBtnGroup(this.rakeBtns, btnOptions) + .syncValues() + .addInputTooltip() + .build(); + + this.rakeEl = this.rakeEls.inputEl; + this.faultStyleEl = this.rakeEls.btnGroupEl; + this.rakeSliderEl = this.rakeEls.sliderEl; + this.faultStyleStrikeEl = this.rakeEls.btnGroupEl + .querySelector('#fault-style-strike'); + this.faultStyleNormalEl = this.rakeEls.btnGroupEl + .querySelector('#fault-style-normal'); + this.faultStyleReverseEl = this.rakeEls.btnGroupEl + .querySelector('#fault-style-reverse'); + } + + /** + * Create a form group for rJB with a: + * - Input form + * - Buttons (hanging wall / footwall) + * + * @param {Object} params The spectra JSON usage. + */ + _createRJBFormGroup(params) { + let inputOptions = { + id: 'rJB', + label: 'RJB', + labelColSize: 'col-xs-2', + max: params.rJB.max, + min: params.rJB.min, + name: 'rJB', + readOnly: true, + value: params.rJB.value, + } + + let btnOptions = { + btnGroupColSize: 'col-xs-6 col-xs-offset-1', + id: 'hw-fw', + paddingTop: 'initial', + }; + + let rJBEls = this.controlPanel.formGroupBuilder() + .addInput(inputOptions) + .addInputAddon(params.rJB.units) + .addInputTooltip() + .addBtnGroup(this.hwFwBtns, btnOptions) + .build(); + + this.rJBEl = rJBEls.inputEl; + this.hwFwEl = rJBEls.btnGroupEl; + this.hwFwHwEl = this.hwFwEl.querySelector('#hw-fw-hw'); + this.hwFwFwEl = this.hwFwEl.querySelector('#hw-fw-fw'); + } + + /** + * Create a form group for rRup with a: + * - Input form + * - Checkbox (Derive rJB and rRup) + * + * @param {Object} params + */ + _createRRupFormGroup(params) { + let inputOptions = { + id: 'rRup', + label: 'RRup', + labelColSize: 'col-xs-2', + max: params.rRup.max, + min: params.rRup.min, + name: 'rRup', + readOnly: true, + value: params.rRup.value, + }; + + let checkboxOptions = { + checked: true, + id: 'r-check', + inputColSize: 'col-xs-6 col-xs-offset-1', + text: 'Derive RJB and RRup', + }; + + let rRupEls = this.controlPanel.formGroupBuilder() + .addInput(inputOptions) + .addInputAddon(params.rRup.units) + .addInputTooltip() + .addCheckbox(checkboxOptions) + .build(); + + this.rRupEl = rRupEls.inputEl; + this.rCheckEl = rRupEls.checkboxEl; + } + + /** + * Create a rX form group with a: + * - Input form + * - Slider + * + * @param {Object} params The spectra JSON usage. + */ + _createRXFormGroup(params) { + let inputOptions = { + id: 'rX', + label: 'RX', + labelColSize: 'col-xs-2', + max: params.rX.max, + min: params.rX.min, + name: 'rX', + value: params.rX.value, + }; + + let sliderOptions = { + id: 'rX-slider', + }; + + let rXEls = this.controlPanel.formGroupBuilder() + .addInput(inputOptions) + .addInputAddon(params.rX.units) + .addInputTooltip() + .addInputSlider(sliderOptions) + .syncValues() + .build(); + + this.rXEl = rXEls.inputEl; + this.rXSliderEl = rXEls.sliderEl; + } + + /** + * Create a form group for rY with a: + * - Input form + * + * NOTE: Currently not implimented. + * + * @param {Object} params The spectra JSON usage. + */ + _createRYFormGroup(params) { + let inputOptions = { + disabled: true, + label: 'RY', + labelColSize: 'col-xs-2', + id: 'rY', + name: 'rY', + }; + + let rYEls = this.controlPanel.formGroupBuilder() + .addInput(inputOptions) + .addInputAddon('km') + .build(); + + this.rYEl = rYEls.inputEl; + } + + /** + * Create site parameters: + * - Vs30 (input form, slider, buttons) + * - Z1p0 (input form) + * - Z2p5 (input form) + * + * @param {Object} params The spectra JSON usage. + */ + _createSiteParameters(params) { + this.controlPanel.createLabel({ + appendTo: this.controlPanel.formHorizontalEl, + label: 'Site & Basin:'}); + + /* Vs30 form group */ + this._createVs30FormGroup(params); + /* Z1p0 */ + this._createZ1p0FormGroup(params); + /* Z2p5 */ + this._createZ2p5FormGroup(params); + } + + _createSourceParameters(params) { + this.controlPanel.createLabel({ + appendTo: this.controlPanel.formHorizontalEl, + label: 'Source Parameters:'}); + + /* zTop form group */ + this._createZTopFormGroup(params); + /* Dip form group */ + this._createDipFormGroup(params); + /* Width form group */ + this._createWidthFormGroup(params); + } + + /** + * Create a form group for vs30 with a: + * - Input form + * - Slider + * - Button group + * + * @param {Object} params The spectra JSON usage. + */ + _createVs30FormGroup(params) { + let inputOptions = { + id: 'vs30', + label: 'VS30', + labelColSize: 'col-xs-2', + max: params.vs30.max, + min: params.vs30.min, + name: 'vs30', + value: params.vs30.value, + }; + + let sliderOptions = { + id: 'vs30-slider', + }; + + let btnOptions = { + id: 'vs30-btn-group', + name: 'vs30', + }; + + this.vs30Els = this.controlPanel.formGroupBuilder() + .addInput(inputOptions) + .addInputAddon(params.vs30.units) + .addInputTooltip() + .addInputSlider(sliderOptions) + .addBtnGroup(this.vs30Btns, btnOptions) + .syncValues() + .build(); + + this.vs30El = this.vs30Els.inputEl; + this.vs30SliderEl = this.vs30Els.sliderEl; + this.vs30BtnGroupEl = this.vs30Els.btnGroupEl; + } + + /** + * Create a form group for width with a: + * - Input form + * - Slider + * + * @param {Object} params The spectra JSON usage. + */ + _createWidthFormGroup(params) { + let inputOptions = { + id: 'width', + label: 'Width', + labelColSize: 'col-xs-2', + max: params.width.max, + min: params.width.min, + name: 'width', + value: params.width.value, + }; + + let sliderOptions = { + id: 'width-slider', + }; + + let widthEls = this.controlPanel.formGroupBuilder() + .addInput(inputOptions) + .addInputAddon(params.width.units) + .addInputTooltip() + .addInputSlider(sliderOptions) + .syncValues() + .build(); + + this.widthEl = widthEls.inputEl; + this.widthSliderEl = widthEls.sliderEl; + } + + /** + * Create a form group for z1p0 with a: + * - Input form + * + * @param {Object} params The spectra JSON usage. + */ + _createZ1p0FormGroup(params) { + let inputOptions = { + id: 'z1p0', + label: 'Z1.0', + labelColSize: 'col-xs-2', + max: params.z1p0.max, + min: params.z1p0.min, + name: 'z1p0', + value: params.z1p0.value, + }; + + let z1p0Els = this.controlPanel.formGroupBuilder() + .addInput(inputOptions) + .addInputAddon(params.z1p0.units) + .addInputTooltip() + .build(); + + this.z1p0El = z1p0Els.inputEl; + } + + /** + * Create a form group for z2p5 with a: + * - Input form + * + * @param {Object} params The spectra JSON usage. + */ + _createZ2p5FormGroup(params) { + let inputOptions = { + id: 'z2p5', + label: 'Z2.5', + labelColSize: 'col-xs-2', + max: params.z2p5.max, + min: params.z2p5.min, + name: 'z2p5', + value: params.z2p5.value, + }; + + let z2p5Els = this.controlPanel.formGroupBuilder() + .addInput(inputOptions) + .addInputAddon(params.z2p5.units) + .addInputTooltip() + .build(); + + this.z2p5El = z2p5Els.inputEl; + } + + /** + * Create a form group for zHyp with a: + * - Input form + * - Checkbox (Centered down dip) + * + * @param {Object} params The spectra JSON usage. + */ + _createZHypFormGroup(params) { + let inputOptions = { + id: 'zHyp', + label: 'zHyp', + labelColSize: 'col-xs-2', + max: params.zHyp.max, + min: params.zHyp.min, + name: 'zHyp', + readOnly: true, + step: 0.5, + value: params.zHyp.value, + }; + + let checkboxOptions = { + checked: true, + id: 'z-check', + inputColSize: 'col-xs-6 col-xs-offset-1', + text: 'Centered down-dip', + }; + + let zHypEls = this.controlPanel.formGroupBuilder() + .addInput(inputOptions) + .addInputAddon(params.zHyp.units) + .addCheckbox(checkboxOptions) + .build(); + + this.zHypEl = zHypEls.inputEl; + this.zCheckEl = zHypEls.checkboxEl; + } + + /** + * Create a form group for zTop with a: + * - Input form + * - Slider + * @param {Object} params The spectra JSON usage. + */ + _createZTopFormGroup(params) { + let inputOptions = { + id: 'zTop', + label: 'zTop', + labelColSize: 'col-xs-2', + max: params.zTop.max, + min: params.zTop.min, + name: 'zTop', + value: params.zTop.value, + }; + + let sliderOptions = { + id: 'zTop-slider', + } + + let zTopEls = this.controlPanel.formGroupBuilder() + .addInput(inputOptions) + .addInputAddon(params.zTop.units) + .addInputTooltip() + .addInputSlider(sliderOptions) + .syncValues() + .build(); + + this.zTopEl = zTopEls.inputEl; + this.zTopSliderEl = zTopEls.sliderEl; + } + + /** + * Event listeners + */ + _listeners() { + d3.select(this.multiSelectEl).datum(this.gmmsEl); + $(this.multiSelectEl).on('input', (event) => { + this._onMultiSelectClick(event); + }); + $(this.multiSelectEl).trigger('input'); + + $('input', this.hwFwEl).on('change', (event) => { + this.updateDistance(); + Tools.resetRadioButton(event.target); + }); + + $(this.rCheckEl).on('change', (event) => { + let rCompute = event.target.checked; + $(this.rJBEl).prop('readonly', rCompute); + $(this.rRupEl).prop('readonly', rCompute); + $(this.hwFwHwEl.parentNode).toggleClass('disabled', !rCompute); + $(this.hwFwFwEl.parentNode).toggleClass('disabled', !rCompute); + this.updateDistance(); + }); + this.updateDistance(); + + + $(this.rXEl).on('input', () => { this.updateDistance(); }); + + $(this.dipEl).on('input', () => { + if (isNaN(this.dip_val())) return; + this.updateDistance(); + this.updateHypoDepth(); + }); + + $(this.widthEl).on('input', () => { + if (isNaN(this.width_val())) return; + this.updateDistance(); + this.updateHypoDepth(); + }); + + $(this.zCheckEl).change((event) => { + $(this.zHypEl).prop('readonly', event.target.checked); + this.updateHypoDepth(); + }); + + $(this.zTopEl).on('input', () => { + if (isNaN(this.zTop_val())) return; + this.updateDistance(); + this.updateHypoDepth(); + }); + + // On any input + $(this.controlPanel.inputsEl) + .on('input change', (event) => { this.inputsOnInput(event); }); + } + + /** + * Update the parameters based on the chosen parameter allowed + * to be multi-selectable. + * + * @param {Event} event The event. + */ + _onMultiSelectClick(event) { + this._resetMultiSelectMenus(); + let param = event.target.value; + + switch(param) { + case 'gmms': + d3.select(this.gmmsEl) + .property('multiple', true) + .attr('size', 16); + d3.select(this.multiSelectEl).datum(this.gmmsEl); + break; + case 'Mw': + this.controlPanel.toMultiSelectable(this.MwEls); + d3.select(this.multiSelectEl).datum(this.MwBtnGroupEl); + break; + case 'vs30': + this.controlPanel.toMultiSelectable(this.vs30Els); + d3.select(this.multiSelectEl).datum(this.vs30BtnGroupEl); + }; + + $(this.controlPanel.inputsEl).trigger('change'); + } + + /** + * Reset the multi-selectable parameters to single selectable. + */ + _resetMultiSelectMenus() { + /* Reset GMMs */ + d3.select(this.gmmsEl) + .property('multiple', false) + .attr('size', 16); + /* Reset magnitude */ + this.controlPanel.toSingleSelectable(this.MwEls); + /* Reset vs30 */ + this.controlPanel.toSingleSelectable(this.vs30Els); + } + + /** + * Convert an array of JSON responses into arrays + * of data, labels, and ids for D3LinePlot. + * + * @param {Array} responses An array of JSON returns + * from gmm/spectra web service + * @param {String} whichDataSet Which data set to get from the + * JSON return: 'means' || 'sigmas' + * @returns {Object} An object with data series information to + * create plots. + */ + _responsesToData(responses, whichDataSet) { + let dataSets = []; + let multiParam = $(':selected', this.multiSelectEl).text(); + let multiParamVal = this.multiSelectEl.value; + + for (let response of responses) { + for (let data of response[whichDataSet].data) { + if (multiParamVal != 'gmms') { + let val = response.request.input[multiParamVal]; + let valStr = val.toString().replace('.', 'p'); + data.id = data.id + '_' + multiParamVal + '_' + valStr; + data.label = data.label + ' - ' + multiParam + ' = ' + val; + } + dataSets.push(data); + } + } + + let seriesLabels = []; + let seriesIds = []; + let seriesData = []; + + dataSets.forEach((d, i) => { + d.data.xs[0] = 'PGA'; + seriesLabels.push(d.label); + seriesIds.push(d.id); + seriesData.push(d3.zip(d.data.xs, d.data.ys)); + }); + + let singleResponse = responses[0][whichDataSet]; + + let dataInfo = { + data: seriesData, + display: singleResponse.label, + ids: seriesIds, + labels: seriesLabels, + xLabel: singleResponse.xLabel, + yLabel: singleResponse.yLabel, + }; + + return dataInfo; + } + + _responsesToLineData(responses, subView, whichDataSet) { + let multiParam = $(':selected', this.multiSelectEl).text(); + let multiParamVal = this.multiSelectEl.value; + + let dataBuilder = D3LineData.builder() + .subView(subView) + .xLimit(this.spectraXDomain); + + let pgaBuilder = D3LineData.builder() + .subView(subView) + .xLimit(this.spectraXDomain); + + for (let response of responses) { + for (let responseData of response[whichDataSet].data) { + if (multiParamVal != 'gmms') { + let val = response.request.input[multiParamVal]; + let valStr = val.toString().replace('.', 'p'); + + responseData.id = responseData.id + '_' + multiParamVal + '_' + valStr; + responseData.label = responseData.label + ' - ' + multiParam + ' = ' + val; + } + + let xValues = responseData.data.xs; + let yValues = responseData.data.ys; + + let iPGA = xValues.indexOf(Tools.imtToValue('PGA')); + xValues.splice(iPGA, 1); + let pgaX = [this.pga]; + let pgaY = yValues.splice(iPGA, 1); + + let pgaOptions = D3LineOptions.builder() + .id(responseData.id) + .label(responseData.label) + .lineStyle('none') + .markerStyle('s') + .markerColor('none') + .markerEdgeWidth(2) + .markerSize(9) + .showInLegend(false) + .build(); + + pgaBuilder.data(pgaX, pgaY, pgaOptions, ['PGA']); + + let lineOptions = D3LineOptions.builder() + .id(responseData.id) + .label(responseData.label) + .markerSize(5) + .build(); + + dataBuilder.data(xValues, yValues, lineOptions); + } + } + + let lineData = dataBuilder.build() + let pgaData = pgaBuilder.build(); + + return new SpectraData(lineData, pgaData); + } + +} + +class SpectraData { + + /** + * Spectra data. + * + * @param {D3LineData} lineData Line data + * @param {D3LineData} pgaData PGA data + */ + constructor(lineData, pgaData) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentInstanceOf(pgaData, D3LineData); + + this.lineData = lineData; + this.pgaData = pgaData; + } + +} diff --git a/webapp/apps/js/Util.js b/webapp/apps/js/Util.js new file mode 100644 index 000000000..8b837198e --- /dev/null +++ b/webapp/apps/js/Util.js @@ -0,0 +1,34 @@ +'use strict'; + +import Footer from './lib/Footer.js'; +import Header from './lib/Header.js'; + +export default class Util{ + + constructor(){ + let _this = this; + + _this.footer = new Footer(); + _this.header = new Header(); + _this.header.setTitle("Utilities"); + + var urlPrefix = window.location.protocol + + "//" + window.location.host + "/nshmp-haz-ws"; + + $(".serviceLink").each(function() { + var serviceUrl = urlPrefix + $(this).text(); + $(this).empty().append($("") + .attr("href", serviceUrl) + .text(serviceUrl)); + }); + + + $(".formatUrl").each(function(){ + var serviceUrl = urlPrefix + $(this).text(); + $(this).empty().text(serviceUrl); + }); + + } + +} + diff --git a/webapp/apps/js/calc/ExceedanceModel.js b/webapp/apps/js/calc/ExceedanceModel.js new file mode 100644 index 000000000..a2b998074 --- /dev/null +++ b/webapp/apps/js/calc/ExceedanceModel.js @@ -0,0 +1,122 @@ + +import { D3XYPair } from '../d3/data/D3XYPair.js'; +import { Maths } from './Maths.js'; +import { Preconditions } from '../error/Preconditions.js'; +import { UncertaintyModel } from './UncertaintyModel.js'; + +export class ExceedanceModel { + + /** + * No truncation; model ignores truncation level n. + * + * Compute the probability of exceeding a value. + * @param {UncertaintyModel} model to compute exceedance + * @param {number} value for which to compute the exceedance probability + */ + static truncationOff(model, value) { + Preconditions.checkArgumentInstanceOf(model, UncertaintyModel); + Preconditions.checkArgumentNumber(value); + + return this._boundedCcdFn(model, value, 0.0, 1.0); + } + + /** + * No truncation; model ignores truncation level n. + * + * Compute the probability of exceeding a value. + * + * @param {UncertaintyModel} model to compute exceedance + * @param {D3XYPair[]} sequence for which to compute + * the exceedance probability + */ + static truncationOffSequence(model, sequence) { + Preconditions.checkArgumentInstanceOf(model, UncertaintyModel); + Preconditions.checkArgumentArrayInstanceOf(sequence, D3XYPair); + + for (let xy of sequence) { + xy.y = this.truncationOff(model, xy.x); + } + + return sequence; + } + + /** + * Upper truncation only at μ + σ * n. + * + * Compute the probability of exceeding a value. + * + * @param {UncertaintyModel} model to compute exceedance + * @param {number} value for which to compute the exceedance probability + */ + static truncationUpperOnly(model, value) { + Preconditions.checkArgumentInstanceOf(model, UncertaintyModel); + Preconditions.checkArgumentNumber(value); + + return this._boundedCcdFn(model, value, this._prob(model), 1.0); + } + + /** + * Upper truncation only at μ + σ * n. + * + * Compute the probability of exceeding a value. + * + * @param {UncertaintyModel} model to compute exceedance + * @param {D3XYPair[]} sequence for which to compute the exceedance probability + */ + static truncationUpperOnlySequence(model, sequence) { + Preconditions.checkArgumentInstanceOf(model, UncertaintyModel); + Preconditions.checkArgumentArrayInstanceOf(sequence, D3XYPair); + + for (let xy of sequence) { + xy.y = this.truncationUpperOnly(model, xy.x); + } + + return sequence; + } + + /** + * @private + * + * Bounded complementary cumulative distribution. Compute the probability that + * a value will be exceeded, subject to upper and lower probability limits. + * + * @param {UncertaintyModel} model to compute exceedance + */ + static _boundedCcdFn(model, value, pHi, pLo) { + Preconditions.checkArgumentInstanceOf(model, UncertaintyModel); + Preconditions.checkArgumentNumber(value); + Preconditions.checkArgumentNumber(pHi); + Preconditions.checkArgumentNumber(pLo); + + const p = Maths.normalCcdf(model.μ, model.σ, value); + return this._probBoundsCheck((p - pHi) / (pLo - pHi)); + } + + /** + * @private + * + * For truncated distributions, p may be out of range. For upper truncations, + * p may be less than pHi, yielding a negative value in boundedCcdFn(); for + * lower truncations, p may be greater than pLo, yielding a value > 1.0 in + * boundedCcdFn(). + */ + static _probBoundsCheck(p) { + Preconditions.checkArgumentNumber(p); + + return (p < 0.0) ? 0.0 : (p > 1.0) ? 1.0 : p; + } + + /** + * @private + * + * Compute ccd value at μ + nσ. + * + * @param {UncertaintyModel} model to compute exceedance + */ + static _prob(model) { + Preconditions.checkArgumentInstanceOf(model, UncertaintyModel); + + return Maths.normalCcdf(model.μ, model.σ, model.μ + model.n * model.σ); + } + +} diff --git a/webapp/apps/js/calc/Maths.js b/webapp/apps/js/calc/Maths.js new file mode 100644 index 000000000..78c4c2d08 --- /dev/null +++ b/webapp/apps/js/calc/Maths.js @@ -0,0 +1,63 @@ + +import { Preconditions } from '../error/Preconditions.js'; + +export class Maths { + + /** + * Normal complementary cumulative distribution function. + * + * @param {number} μ mean + * @param {number} σ standard deviation + * @param {number} x variate + */ + static normalCcdf(μ, σ, x) { + Preconditions.checkArgumentNumber(μ); + Preconditions.checkArgumentNumber(σ); + Preconditions.checkArgumentNumber(x); + + return (1.0 + this.erf((μ - x) / (σ * Math.sqrt(2)))) * 0.5; + } + + /** + * Error function approximation of Abramowitz and Stegun, formula 7.1.26 in + * the Handbook of Mathematical Functions with Formulas, Graphs, and + * Mathematical Tables. Although the approximation is only valid for + * x ≥ 0, because erf(x) is an odd function, + * erf(x) = −erf(−x) and negative values are supported. + */ + static erf(x) { + Preconditions.checkArgumentNumber(x); + + return x < 0.0 ? -this._erfBase(-x) : this._erfBase(x); + } + + static round(value, scale) { + Preconditions.checkArgumentNumber(value); + Preconditions.checkArgumentInteger(scale); + + let format = d3.format(`.${scale}f`); + + return Number(format(value)); + } + + static _erfBase(x) { + Preconditions.checkArgumentNumber(x); + + const P = 0.3275911; + const A1 = 0.254829592; + const A2 = -0.284496736; + const A3 = 1.421413741; + const A4 = -1.453152027; + const A5 = 1.061405429; + + const t = 1 / (1 + P * x); + const tsq = t * t; + + return 1 - (A1 * t + + A2 * tsq + + A3 * tsq * t + + A4 * tsq * tsq + + A5 * tsq * tsq * t) * Math.exp(-x * x); + } + +} diff --git a/webapp/apps/js/calc/UncertaintyModel.js b/webapp/apps/js/calc/UncertaintyModel.js new file mode 100644 index 000000000..13a0eac88 --- /dev/null +++ b/webapp/apps/js/calc/UncertaintyModel.js @@ -0,0 +1,33 @@ + +import { Preconditions } from '../error/Preconditions.js'; + +/** + * @fileoverview Container class for mean, standard deviation, and + * truncation level. + * + * @class UncertaintyModel + * @author Brandon Clayton + */ +export class UncertaintyModel { + + /** + * @param {number} μ mean + * @param {number} σ standard deviation + * @param {number} n truncation level in units of σ (truncation = n * σ) + */ + constructor(μ, σ, n) { + Preconditions.checkArgumentNumber(μ); + Preconditions.checkArgumentNumber(σ); + Preconditions.checkArgumentNumber(n); + + /** Mean */ + this.μ = μ; + + /** Standard deviation */ + this.σ = σ; + + /** Truncation level */ + this.n = n; + } + +} \ No newline at end of file diff --git a/webapp/apps/js/d3/D3LinePlot.js b/webapp/apps/js/d3/D3LinePlot.js new file mode 100644 index 000000000..c0de08a86 --- /dev/null +++ b/webapp/apps/js/d3/D3LinePlot.js @@ -0,0 +1,1630 @@ + +import { D3LineAxes } from './axes/D3LineAxes.js'; +import { D3LineData } from './data/D3LineData.js'; +import { D3LineOptions } from './options/D3LineOptions.js'; +import { D3LineLegend } from './legend/D3LineLegend.js'; +import { D3LineSeriesData } from './data/D3LineSeriesData.js'; +import { D3LineSubView } from './view/D3LineSubView.js'; +import { D3LineView } from './view/D3LineView.js'; +import { D3SaveFigure} from './D3SaveFigure.js'; +import { D3SaveLineData } from './D3SaveLineData.js'; +import { D3TextOptions } from './options/D3TextOptions.js'; +import { D3Tooltip } from './D3Tooltip.js'; +import { D3Utils } from './D3Utils.js'; +import { D3XYPair } from './data/D3XYPair.js'; + +import { Preconditions } from '../error/Preconditions.js'; + +/** + * @fileoverview Plot D3LineData + * + * @class D3LinePlot + * @author Brandon Clayton + */ +export class D3LinePlot { + + /** + * New D3LinePlot instance. + * + * @param {D3LineView} view The line view + */ + constructor(view) { + Preconditions.checkArgumentInstanceOf(view, D3LineView); + + /** @type {D3LineView} */ + this.view = view; + + /** @type {D3LineAxes} */ + this.axes = new D3LineAxes(this.view); + + /** @type {D3LineData} */ + this.upperLineData = undefined; + this._setLineData(this._getDefaultUpperLineData()); + + /** @type {D3LineData} */ + this.lowerLineData = undefined; + if (this.view.addLowerSubView) { + this._setLineData(this._getDefaultLowerLineData()); + } + + /** @type {D3Tooltip} */ + this.tooltip = new D3Tooltip(); + + /** @type {D3LineLegend} */ + this.legend = new D3LineLegend(this); + + this._addDefaultAxes(); + this._addEventListeners(); + } + + /** + * Select lines on multiple sub views that have the same id. + * + * @param {String} id The id of the lines to select + * @param {Array} linePlots The line plots + * @param {Array} lineDatas The line data + */ + static selectLineOnSubViews(id, linePlots, lineDatas) { + Preconditions.checkArgumentString(id); + Preconditions.checkArgumentArrayInstanceOf(linePlots, D3LinePlot); + Preconditions.checkArgumentArrayInstanceOf(lineDatas, D3LineData); + Preconditions.checkState( + linePlots.length == lineDatas.length, + 'Number of line plots and line datas must be the same'); + + for (let i = 0; i < linePlots.length; i++) { + let linePlot = linePlots[i]; + let lineData = lineDatas[i]; + + Preconditions.checkStateInstanceOf(linePlot, D3LinePlot); + Preconditions.checkStateInstanceOf(lineData, D3LineData); + + linePlot.selectLine(id, lineData); + } + } + + /** + * Sync selections between multiple sub views. + * + * @param {Array} linePlots The line plots + * @param {Array} lineDatas The line data + */ + static syncSubViews(linePlots, lineDatas) { + Preconditions.checkArgumentArrayInstanceOf(linePlots, D3LinePlot); + Preconditions.checkArgumentArrayInstanceOf(lineDatas, D3LineData); + Preconditions.checkState( + linePlots.length == lineDatas.length, + 'Number of line plots and line datas must be the same'); + + for (let lineData of lineDatas) { + d3.select(lineData.subView.svg.dataContainerEl) + .selectAll('.data-enter') + .on('click', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + D3LinePlot.selectLineOnSubViews( + series.lineOptions.id, + linePlots, + lineDatas); + }); + } + } + + /** + * Add text to a sub view's plot. + * + * @param {D3LineSubView} subView The sub view to add text + * @param {Number} x The X coordinate of text + * @param {Number} y The Y coordinate of text + * @param {String} text The text + * @param {D3TextOptions=} textOptions Optional text options + * @returns {SVGElement} The text element + */ + addText(subView, x, y, text, textOptions = D3TextOptions.withDefaults()) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentNumber(x); + Preconditions.checkArgumentNumber(y); + Preconditions.checkArgumentString(text); + Preconditions.checkArgumentInstanceOf(textOptions, D3TextOptions); + + let textD3 = d3.select(subView.svg.dataContainerEl) + .append('g') + .attr('class', 'text-enter') + .append('text') + .datum(new D3XYPair(x, y)); + + let textEl = textD3.node(); + Preconditions.checkStateInstanceOfSVGElement(textEl); + + this.moveText(subView, x, y, textEl); + this.updateText(textEl, text); + this.updateTextOptions(textEl, textOptions); + + return textEl; + } + + /** + * Clear all plots off a D3LineSubView. + * + * @param {D3LineSubView} subView + */ + clear(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + this.legend.remove(subView); + + d3.select(subView.svg.dataContainerEl).datum(null); + + d3.select(subView.svg.dataContainerEl) + .selectAll('*') + .remove(); + } + + /** + * Clear all plots off the sub views + */ + clearAll() { + this.clear(this.view.upperSubView); + + if (this.view.addLowerSubView) this.clear(this.view.lowerSubView); + } + + /** + * Get the current X domain of the plot. + * + * @param {D3LineSubView} subView The sub view to get domain + * @returns {Array} The X axis domain: [ xMin, xMax ] + */ + getXDomain(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + let lineData = subView.options.subViewType == 'lower' ? + this.lowerLineData : this.upperLineData; + + return this.axes._getXAxisScale( + lineData, + this._getCurrentXScale(lineData.subView)) + .domain(); + } + + /** + * Get the current Y domain of the plot. + * + * @param {D3LineSubView} subView The sub view to get the domain + * @returns {Array} The Y axis domain: [ yMin, yMax ] + */ + getYDomain(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + let lineData = subView.options.subViewType == 'lower' ? + this.lowerLineData : this.upperLineData; + + return this.axes._getYAxisScale( + lineData, + this._getCurrentYScale(lineData.subView)) + .domain(); + } + + /** + * Make a vertical line draggable. + * + * @param {D3LineSubView} subView The sub view were the line is to drag + * @param {SVGElement} refLineEl The reference line element + * @param {Array} xLimit The limits that the line can be dragged + * @param {Function} callback The funciton to call when the line is dragged. + * The arguments passed to the callback function are + * (Number, D3LineSeriesData, SVGElement) where: + * - Number is the current X value + * - D3LineSeriesData is updated line series data + * - SVGElement is element being dragged + */ + makeDraggableInX(subView, refLineEl, xLimit, callback = () => {}) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentInstanceOfSVGElement(refLineEl); + Preconditions.checkArgumentArrayLength(xLimit, 2); + Preconditions.checkState( + xLimit[0] < xLimit[1], + `X limit min [${xLimit[0]}] must be less than X limit max [${xLimit[1]}]`); + Preconditions.checkArgumentInstanceOf(callback, Function); + + let lineData = subView.options.subViewType == 'lower' ? + this.lowerLineData : this.upperLineData; + + d3.select(refLineEl).select('.plot-line').style('cursor', 'col-resize'); + + d3.selectAll([ refLineEl ]) + .on('click', null) + .call(d3.drag() + .on('start', (/** @type {D3LineSeriesData*/ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + this._onDragStart(series, refLineEl); + }) + .on('end', (/** @type {D3LineSeriesData*/ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + this._onDragEnd(series, refLineEl); + }) + .on('drag', (/** @type {D3LineSeriesData */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + let xValues = series.xValues; + Preconditions.checkStateArrayLength(xValues, 2); + Preconditions.checkState( + xValues[0] == xValues[1], + 'Not a vertical line'); + this._onDragInX(lineData, series, refLineEl, xLimit, callback); + })); + } + + /** + * Make a horizontal line draggable. + * + * @param {D3LineSubView} subView The sub view were the line is to drag + * @param {SVGElement} refLineEl The reference line element + * @param {Array} yLimit The limits that the line can be dragged + * @param {Function} callback The funciton to call when the line is dragged. + * The arguments passed to the callback function are + * (Number, D3LineSeriesData, SVGElement) where: + * - Number is the current Y value + * - D3LineSeriesData is updated line series data + * - SVGElement is element being dragged + */ + makeDraggableInY(subView, refLineEl, yLimit, callback = () => {}) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentInstanceOfSVGElement(refLineEl); + Preconditions.checkArgumentArrayLength(yLimit, 2); + Preconditions.checkState( + yLimit[0] < yLimit[1], + `Y limit min [${yLimit[0]}] must be less than Y limit max [${yLimit[1]}]`); + Preconditions.checkArgumentInstanceOf(callback, Function); + + let lineData = subView.options.subViewType == 'lower' ? + this.lowerLineData : this.upperLineData; + + d3.select(refLineEl).select('.plot-line').style('cursor', 'row-resize'); + + d3.selectAll([ refLineEl ]) + .selectAll('.plot-line') + .on('click', null) + .call(d3.drag() + .on('start', (/** @type {D3LineSeriesData*/ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + this._onDragStart(series, refLineEl); + }) + .on('end', (/** @type {D3LineSeriesData*/ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + this._onDragEnd(series, refLineEl); + }) + .on('drag', (/** @type {D3LineSeriesData */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + let yValues = series.yValues; + Preconditions.checkStateArrayLength(yValues, 2); + Preconditions.checkState( + yValues[0] == yValues[1], + 'Not a horizontal line'); + this._onDragInY(lineData, series, refLineEl, yLimit, callback); + })); + } + + /** + * Move a text element to a new location. + * + * @param {D3LineSubView} subView The sub view of the text + * @param {Number} x The new X coordinate + * @param {Number} y The new Y coordinate + * @param {SVGElement} textEl The text element + */ + moveText(subView, x, y, textEl) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentNumber(x); + Preconditions.checkArgumentNumber(y); + Preconditions.checkArgumentInstanceOfSVGElement(textEl); + + let xScale = this._getCurrentXScale(subView); + let yScale = this._getCurrentYScale(subView); + + let lineData = subView.options.subViewType == 'lower' ? + this.lowerLineData : this.upperLineData; + + let xyPair = new D3XYPair(x, y); + + d3.select(textEl) + .datum(xyPair) + .attr('x', this.axes.x(lineData, xScale, xyPair)) + .attr('y', this.axes.y(lineData, yScale, xyPair)); + } + + /** + * Fire a custom function when a line or symbol is selected. + * Arguments passed to the callback function: + * - D3LineSeriesData: The series data from the plot selection + * + * @param {D3LineData} lineData The line data + * @param {Function} callback Function to call when plot is selected + */ + onPlotSelection(lineData, callback) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentInstanceOf(callback, Function); + + lineData.subView.svg.dataContainerEl.addEventListener('plotSelection', (e) => { + let series = e.detail; + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + callback(series); + }); + } + + /** + * Creates a 2-D line plot from D3LineData. + * + * @param {D3LineData} lineData The line data to plot + */ + plot(lineData) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + + lineData = this._dataEnter(lineData); + + let xScale = this._getCurrentXScale(lineData.subView); + let yScale = this._getCurrentYScale(lineData.subView); + + this.axes.createXAxis(lineData, xScale); + this.axes.createYAxis(lineData, yScale); + + this.legend.create(lineData); + + this._plotUpdateHorizontalRefLine(lineData, xScale, yScale); + this._plotUpdateVerticalRefLine(lineData, xScale, yScale); + this._plotSelectionEventListener(lineData); + } + + /** + * Plot a reference line at zero, y=0. + * + * @param {D3LineSubView} subView The sub view to add the line to + */ + plotZeroRefLine(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + let lineOptions = D3LineOptions.builder() + .color(subView.options.referenceLineColor) + .id('zero-ref-line') + .lineWidth(subView.options.referenceLineWidth) + .markerSize(0) + .selectable(false) + .showInLegend(false) + .build(); + + let refLineEl = this.plotHorizontalRefLine(subView, 0, lineOptions); + + d3.select(refLineEl).lower(); + } + + /** + * Plot a horizontal reference line, y=value. + * + * @param {D3LineSubView} subView The sub view to put reference line + * @param {Number} y The Y value of reference line + * @param {D3LineOptions=} lineOptions The line options + * @returns {SVGElement} The reference line element + */ + plotHorizontalRefLine( + subView, + y, + lineOptions = D3LineOptions.withRefLineDefaults()) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentNumber(y); + Preconditions.checkArgumentInstanceOf(lineOptions, D3LineOptions); + + let series = new D3LineSeriesData( + this.getXDomain(subView), + [ y, y ], + lineOptions); + + let lineData = subView.options.subViewType == 'lower' ? + this.lowerLineData : this.upperLineData; + + lineData = D3LineData.builder() + .of(lineData) + .data(series.xValues, series.yValues, series.lineOptions) + .build(); + + let refLineD3 = d3.select(subView.svg.dataContainerEl) + .append('g') + .attr('class', 'data-enter-ref-line horizontal-ref-line') + .attr('id', lineOptions.id); + + let refLineEl = refLineD3.node(); + Preconditions.checkStateInstanceOfSVGElement(refLineEl); + + this._plotRefLine(lineData, series, refLineEl); + + return refLineEl; + } + + /** + * Plot a vertical reference line, x=value. + * + * @param {D3LineSubView} subView The sub view to put reference line + * @param {Number} x The X value of reference line + * @param {D3LineOptions=} lineOptions The line options + * @returns {SVGElement} The reference line element + */ + plotVerticalRefLine( + subView, + x, + lineOptions = D3LineOptions.withRefLineDefaults()) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentNumber(x); + Preconditions.checkArgumentInstanceOf(lineOptions, D3LineOptions); + + let series = new D3LineSeriesData( + [ x, x ], + this.getYDomain(subView), + lineOptions); + + let lineData = subView.options.subViewType == 'lower' ? + this.lowerLineData : this.upperLineData; + + lineData = D3LineData.builder() + .of(lineData) + .data(series.xValues, series.yValues, series.lineOptions) + .build(); + + let refLineD3 = d3.select(subView.svg.dataContainerEl) + .append('g') + .attr('class', 'data-enter-ref-line vertical-ref-line') + .attr('id', lineOptions.id); + + let refLineEl = refLineD3.node(); + Preconditions.checkStateInstanceOfSVGElement(refLineEl); + + this._plotRefLine(lineData, series, refLineEl); + + return refLineEl; + } + + /** + * Get an SVG element in a sub view's plot based on the data's id. + * + * @param {D3LineSubView} subView The sub view the data element is in + * @param {String} id The id of the data element + * @returns {SVGElement} The SVG element with that id + */ + querySelector(subView, id) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentString(id); + + let dataEl = subView.svg.dataContainerEl.querySelector(`#${id}`); + Preconditions.checkNotNull( + dataEl, + `Id [${id}] not found in [${subView.options.subViewType}] sub view`); + Preconditions.checkStateInstanceOfSVGElement(dataEl); + + return dataEl; + } + + /** + * Get SVG elements in a sub view's plot based on the data's id. + * + * @param {D3LineSubView} subView The sub view the data element is in + * @param {String} id The id of the data element + * @returns {NodeList} Node list of SVG elements with that id + */ + querySelectorAll(subView, id) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentString(id); + + let dataEls = subView.svg.dataContainerEl.querySelectorAll(`#${id}`); + Preconditions.checkStateInstanceOf(dataEls, NodeList); + Preconditions.checkState( + dataEls.length > 0, + `Id [${id}] not found in [${subView.options.subViewType}] sub view`); + + for (let el of dataEls) { + Preconditions.checkStateInstanceOfSVGElement(el); + } + + return dataEls; + } + + /** + * Select lines of multiple line data given an id. + * + * @param {String} id of line to select + * @param {...D3LineData} lineDatas The line data + */ + selectLine(id, ...lineDatas) { + Preconditions.checkArgumentString(id); + + this.legend.selectLegendEntry(id, ...lineDatas); + + for (let lineData of lineDatas) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + this._resetPlotSelection(lineData); + + d3.select(lineData.subView.svg.dataContainerEl) + .selectAll(`#${id}`) + .each(( + /** @type {D3LineSeriesData} */ series, + /** @type {Number} */ i, + /** @type {NodeList} */ els) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + Preconditions.checkStateInstanceOfSVGElement(els[i]); + this._plotSelection(lineData, series, els[i]); + }); + } + } + + /** + * Sync the plot selections between the upper and lower sub views. + */ + syncSubViews() { + this.legend.syncSubViews(); + for (let lineData of [this.upperLineData, this.lowerLineData]) { + d3.select(lineData.subView.svg.dataContainerEl) + .selectAll('.data-enter') + .on('click', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + this.selectLine( + series.lineOptions.id, + this.upperLineData, + this.lowerLineData); + }); + } + } + + /** + * Update the text on a text element. + * + * @param {SVGElement} textEl The text element + * @param {String} text The new text + */ + updateText(textEl, text) { + Preconditions.checkArgumentInstanceOfSVGElement(textEl); + Preconditions.checkArgumentString(text); + + d3.select(textEl).text(text) + } + + /** + * Update the text on a text element. + * + * @param {SVGElement} textEl The text element + * @param {D3TextOptions=} textOptions Optional text options + */ + updateTextOptions(textEl, textOptions) { + Preconditions.checkArgumentInstanceOfSVGElement(textEl); + Preconditions.checkArgumentInstanceOf(textOptions, D3TextOptions); + + let cxRotate = textEl.getAttribute('x'); + let cyRotate = textEl.getAttribute('y'); + + d3.select(textEl) + .attr('alignment-baseline', textOptions.alignmentBaseline) + .attr('dx', textOptions.dx) + .attr('dy', -textOptions.dy) + .attr('fill', textOptions.color) + .attr('stroke', textOptions.stroke) + .attr('stroke-width', textOptions.strokeWidth) + .attr('transform', `rotate(${textOptions.rotate}, ${cxRotate}, ${cyRotate})`) + .style('font-size', `${textOptions.fontSize}px`) + .style('font-weight', textOptions.fontWeight) + .style('text-anchor', textOptions.textAnchor); + } + + /** + * @private + * Add the default X and Y axes. + * + * Based on D3LineSubViewOptions.defaultXLimit and + * D3LineSubViewOptions.defaultYLimit. + */ + _addDefaultAxes() { + this.axes.createXAxis( + this.upperLineData, + this.view.getXAxisScale(this.view.upperSubView)); + + this.axes.createYAxis( + this.upperLineData, + this.view.getYAxisScale(this.view.upperSubView)); + + if (this.view.addLowerSubView) { + this.axes.createXAxis( + this.lowerLineData, + this.view.getXAxisScale(this.view.lowerSubView)); + + this.axes.createYAxis( + this.lowerLineData, + this.view.getYAxisScale(this.view.lowerSubView)); + } + } + + /** + * @private + * Add all event listeners + */ + _addEventListeners() { + this.view.viewFooter.saveMenuEl.querySelectorAll('a').forEach((el) => { + el.addEventListener('click', (e) => { + this._onSaveMenu(e); + }); + }); + + this.view.viewFooter.xAxisBtnEl.addEventListener('click', () => { + this._onXAxisClick(event); + }); + + this.view.viewFooter.yAxisBtnEl.addEventListener('click', () => { + this._onYAxisClick(event); + }); + + this.view.viewHeader.gridLinesCheckEl.addEventListener('click', () => { + this._onGridLineIconClick(); + }); + + this.view.viewHeader.legendCheckEl.addEventListener('click', () => { + this._onLegendIconClick(); + }); + } + + /** + * @private + * Enter all data from D3LineData.series and any existing data + * into new SVG elements. + * + * @param {D3LineData} lineData The data + */ + _dataEnter(lineData) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + + /** @type {Array} */ + let currentLineData = d3.select(lineData.subView.svg.dataContainerEl) + .datum(); + + let data = currentLineData || []; + data.push(lineData); + let updatedLineData = D3LineData.of(...data); + + d3.select(lineData.subView.svg.dataContainerEl) + .selectAll('.data-enter') + .remove(); + + let seriesEnter = d3.select(lineData.subView.svg.dataContainerEl) + .datum([ updatedLineData ]) + .selectAll('.data-enter') + .data(updatedLineData.series); + + seriesEnter.exit().remove(); + + let seriesDataEnter = seriesEnter.enter() + .append('g') + .attr('class', 'data-enter') + .attr('id', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return series.lineOptions.id; + }); + + /* Update upperLineData or lowerLineData */ + this._setLineData(updatedLineData); + + let seriesDataEnterEls = seriesDataEnter.nodes(); + this._plotLine(updatedLineData, seriesDataEnterEls); + this._plotSymbol(updatedLineData, seriesDataEnterEls); + + return updatedLineData; + } + + /** + * @private + * Returns a default D3LineData for the lower sub view to + * show a empty plot on startup. + * + * @returns {D3LineData} The default line data for lower view + */ + _getDefaultLowerLineData() { + let lowerXLimit = this.view.lowerSubView.options.defaultXLimit; + let lowerYLimit = this.view.lowerSubView.options.defaultYLimit; + + let lowerLineData = D3LineData.builder() + .xLimit(lowerXLimit) + .yLimit(lowerYLimit) + .subView(this.view.lowerSubView) + .build(); + + return lowerLineData; + } + + /** + * @private + * Returns a default D3LineData for the upper sub view to + * show a empty plot on startup. + * + * @returns {D3LineData} The default line data for upper view + */ + _getDefaultUpperLineData() { + let upperXLimit = this.view.upperSubView.options.defaultXLimit; + let upperYLimit = this.view.upperSubView.options.defaultYLimit; + + let upperLineData = D3LineData.builder() + .xLimit(upperXLimit) + .yLimit(upperYLimit) + .subView(this.view.upperSubView) + .build(); + + return upperLineData; + } + + /** + * @private + * Get the current X scale of a D3LineSubView, either: 'log' || 'linear' + * + * @param {D3LineSubView} subView The sub view to get X scale + * @returns {String} The X scale: 'log' || 'linear' + */ + _getCurrentXScale(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + if (this.view.viewOptions.syncXAxisScale || + subView.options.subViewType == 'upper') { + return this.view.viewFooter.xLinearBtnEl.classList.contains('active') ? + this.view.viewFooter.xLinearBtnEl.getAttribute('value') : + this.view.viewFooter.xLogBtnEl.getAttribute('value'); + } else { + return subView.options.xAxisScale; + } + } + + /** + * @private + * Get the current Y scale of a D3LineSubView, either: 'log' || 'linear' + * + * @param {D3LineSubView} subView The sub view to get Y scale + * @returns {String} The Y scale: 'log' || 'linear' + */ + _getCurrentYScale(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + if (this.view.viewOptions.syncYAxisScale || + subView.options.subViewType == 'upper') { + return this.view.viewFooter.yLinearBtnEl.classList.contains('active') ? + this.view.viewFooter.yLinearBtnEl.getAttribute('value') : + this.view.viewFooter.yLogBtnEl.getAttribute('value'); + } else { + return subView.options.yAxisScale; + } + } + + /** + * @private + * Handler to add the grid lines when the grid lines icon is checked. + */ + _onAddGridLines() { + this.view.viewHeader.gridLinesCheckEl.setAttribute('checked', 'true'); + + this.axes.createXGridLines( + this.upperLineData, + this._getCurrentXScale(this.view.upperSubView)); + + this.axes.createYGridLines( + this.upperLineData, + this._getCurrentYScale(this.view.upperSubView)); + + if (this.view.addLowerSubView) { + this.axes.createXGridLines( + this.lowerLineData, + this._getCurrentXScale(this.view.lowerSubView)); + + this.axes.createYGridLines( + this.lowerLineData, + this._getCurrentYScale(this.view.lowerSubView)); + } + } + + /** + * @private + * Event handler for mouse over plot symbols; add tooltip. + * + * @param {D3LineData} lineData The line data + * @param {D3LineSeriesData} series The data series + */ + _onDataSymbolMouseover(lineData, series) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + + let xScale = this._getCurrentXScale(lineData.subView); + let yScale = this._getCurrentYScale(lineData.subView); + + let xyPair = series.data[0]; + let tooltipX = this.axes.x(lineData, xScale, xyPair); + let tooltipY = this.axes.y(lineData, yScale, xyPair); + + let subViewOptions = lineData.subView.options; + + let x = subViewOptions.xValueToExponent ? + xyPair.x.toExponential(subViewOptions.xExponentFractionDigits) : + xyPair.x; + + let y = subViewOptions.yValueToExponent ? + xyPair.y.toExponential(subViewOptions.yExponentFractionDigits) : + xyPair.y; + + let tooltipText = [ + `${lineData.subView.options.lineLabel}: ${series.lineOptions.label}`, + `${lineData.subView.options.xLabel}: ${xyPair.xString || x}`, + `${lineData.subView.options.yLabel}: ${xyPair.yString || y}`, + ]; + + this.tooltip.create(lineData.subView, tooltipText, tooltipX, tooltipY); + } + + /** + * @private + * Event handler for mouse out of plot symols; remove toolip. + * + * @param {D3LineData} lineData The line data + */ + _onDataSymbolMouseout(lineData) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + this.tooltip.remove(lineData.subView); + } + + /** + * @private + * Drag line in X direction. + * + * @param {D3LineData} lineData The line data + * @param {D3LineSeriesData} series The series data + * @param {SVGElement} dataEl The element being dragged + * @param {Array} yLimit The Y limit + * @param {Function} callback The function to call + */ + _onDragInX(lineData, series, dataEl, xLimit, callback) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOfSVGElement(dataEl); + Preconditions.checkArgumentArrayLength(xLimit, 2); + Preconditions.checkArgumentInstanceOf(callback, Function); + + d3.event.sourceEvent.stopPropagation(); + + let xScale = this._getCurrentXScale(lineData.subView); + let yScale = this._getCurrentYScale(lineData.subView); + let xBounds = this.axes._getXAxisScale(lineData, xScale); + let xDomain = xBounds.domain(); + + let xMinLimit = xLimit[0] < xDomain[0] ? xDomain[0] : xLimit[0]; + let xMaxLimit = xLimit[1] > xDomain[1] ? xDomain[1] : xLimit[1]; + + let x = xBounds.invert(d3.event.x); + x = x < xMinLimit ? xMinLimit : x > xMaxLimit ? xMaxLimit : x; + + let snapTo = 1 / lineData.subView.options.dragLineSnapTo; + x = Math.round( x * snapTo ) / snapTo; + let xValues = series.xValues.map(() => { return x; }); + + let updatedSeries = new D3LineSeriesData( + xValues, + series.yValues, + series.lineOptions, + series.xStrings, + series.yStrings); + + d3.select(dataEl).raise(); + callback(x, updatedSeries, dataEl); + let duration = 0; + this._plotUpdateDataEl(lineData, updatedSeries, dataEl, xScale, yScale, duration); + } + + /** + * @private + * Drag line in Y direction. + * + * @param {D3LineData} lineData The line data + * @param {D3LineSeriesData} series The series data + * @param {SVGElement} dataEl The element being dragged + * @param {Array} yLimit The Y limit + * @param {Function} callback The function to call + */ + _onDragInY(lineData, series, dataEl, yLimit, callback) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOfSVGElement(dataEl); + Preconditions.checkArgumentArrayLength(yLimit, 2); + Preconditions.checkArgumentInstanceOf(callback, Function); + + d3.event.sourceEvent.stopPropagation(); + + let xScale = this._getCurrentXScale(lineData.subView); + let yScale = this._getCurrentYScale(lineData.subView); + let yBounds = this.axes._getYAxisScale(lineData, yScale); + let yDomain = yBounds.domain(); + + let yMinLimit = yLimit[0] < yDomain[0] ? yDomain[0] : yLimit[0]; + let yMaxLimit = yLimit[1] > yDomain[1] ? yDomain[1] : yLimit[1]; + + let y = yBounds.invert(d3.event.y); + y = y < yMinLimit ? yMinLimit : y > yMaxLimit ? yMaxLimit : y; + + let snapTo = 1 / lineData.subView.options.dragLineSnapTo; + y = Math.round( y * snapTo ) / snapTo; + let yValues = series.yValues.map(() => { return y; }); + + let updatedSeries = new D3LineSeriesData( + series.xValues, + yValues, + series.lineOptions, + series.xStrings, + series.yStrings); + + d3.select(dataEl).raise(); + callback(y, updatedSeries, dataEl); + let duration = 0; + this._plotUpdateDataEl(lineData, updatedSeries, dataEl, xScale, yScale, duration); + } + + /** + * @private + * Reset line and symbol size on drag end. + * + * @param {D3LineSeriesData} series The series + * @param {SVGElement} dataEl + */ + _onDragEnd(series, dataEl) { + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOfSVGElement(dataEl); + + d3.select(dataEl) + .selectAll('.plot-line') + .attr('stroke-width', series.lineOptions.lineWidth); + + d3.select(dataEl) + .selectAll('.plot-symbol') + .attr('d', series.d3Symbol.size(series.lineOptions.d3SymbolSize)()) + .attr('stroke-width', series.lineOptions.markerEdgeWidth); + } + + /** + * @private + * Increase line and symbol size on drag start. + * + * @param {D3LineSeriesData} series The series + * @param {SVGElement} dataEl + */ + _onDragStart(series, dataEl) { + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOfSVGElement(dataEl); + + let lineOptions = series.lineOptions; + let lineWidth = lineOptions.lineWidth * lineOptions.selectionMultiplier; + let symbolSize = lineOptions.d3SymbolSize * lineOptions.selectionMultiplier; + let edgeWidth = lineOptions.markerEdgeWidth * lineOptions.selectionMultiplier; + + d3.select(dataEl) + .selectAll('.plot-line') + .attr('stroke-width', lineWidth); + + d3.select(dataEl) + .selectAll('.plot-symbol') + .attr('d', series.d3Symbol.size(symbolSize)()) + .attr('stroke-width', edgeWidth); + } + + /** + * @private + * Event handler to add or remove the grid lines when the grid lines + * icon is clicked. + */ + _onGridLineIconClick() { + let isChecked = this.view.viewHeader.gridLinesCheckEl.getAttribute('checked'); + + d3.select(this.view.viewHeader.gridLinesCheckEl) + .style('color', !isChecked ? 'black' : '#bfbfbf'); + + if (isChecked) { + this._onRemoveGridLines(); + } else { + this._onAddGridLines(); + } + } + + /** + * @private + * Event handler for legend icon click; add/remove legend. + * + * @param {D3LineData} lineData The line data + */ + _onLegendIconClick() { + let isChecked = this.view.viewHeader.legendCheckEl.getAttribute('checked'); + + if (isChecked) { + this.view.viewHeader.legendCheckEl.removeAttribute('checked'); + this.legend.hideAll(); + } else { + this.view.viewHeader.legendCheckEl.setAttribute('checked', 'true'); + this.legend.showAll(); + } + } + + /** + * @private + * Handler to remove the grid lines when the grid lines icon is + * not checked + */ + _onRemoveGridLines() { + this.view.viewHeader.gridLinesCheckEl.removeAttribute('checked'); + + this.axes.removeXGridLines(this.view.upperSubView); + this.axes.removeYGridLines(this.view.upperSubView); + + if (this.view.addLowerSubView) { + this.axes.removeXGridLines(this.view.lowerSubView); + this.axes.removeYGridLines(this.view.lowerSubView); + } + } + + /** + * Save/preview figure or data + * + * @param {Event} event The event + */ + _onSaveMenu(event) { + let saveType = event.target.getAttribute('data-type'); + let saveFormat = event.target.getAttribute('data-format'); + let imageOnly = this.view.viewFooter.imageOnlyEl.checked; + + switch(saveType) { + case 'save-figure': + if (imageOnly) D3SaveFigure.saveImageOnly(this.view, saveFormat); + else D3SaveFigure.save(this.view, saveFormat); + break; + case 'preview-figure': + if (imageOnly) D3SaveFigure.previewImageOnly(this.view, saveFormat); + else D3SaveFigure.preview(this.view, saveFormat); + break; + case 'save-data': + Preconditions.checkNotUndefined( + this.view.getSaveData(), + 'Must set the save data, D3LineView.setSaveData()'); + D3SaveLineData.saveCSV(...this.view.getSaveData()); + break; + default: + throw new NshmpError(`Save type [${saveType}] not supported`); + } + } + + /** + * @private + * Update the plot when the X axis buttons are clicked. + * + * @param {Event} event The click event + */ + _onXAxisClick(event) { + if (event.target.hasAttribute('disabled')) return; + + let xScale = event.target.getAttribute('value'); + let yScaleUpper = this._getCurrentYScale(this.view.upperSubView); + + this.axes.createXAxis(this.upperLineData, xScale); + this._plotUpdate(this.upperLineData, xScale, yScaleUpper); + + if (this.view.addLowerSubView && this.view.viewOptions.syncXAxisScale) { + let yScaleLower = this._getCurrentYScale(this.view.lowerSubView); + this.axes.createXAxis(this.lowerLineData, xScale); + this._plotUpdate(this.lowerLineData, xScale, yScaleLower); + } + } + + /** + * @private + * Update the plot when the Y axus buttons are clicked. + * + * @param {Event} event The click event + */ + _onYAxisClick(event) { + if (event.target.hasAttribute('disabled')) return; + + let xScaleUpper = this._getCurrentXScale(this.view.upperSubView); + let yScale = event.target.getAttribute('value'); + + this.axes.createYAxis(this.upperLineData, yScale); + this._plotUpdate(this.upperLineData, xScaleUpper, yScale); + + if (this.view.addLowerSubView && this.view.viewOptions.syncYAxisScale) { + let xScaleLower = this._getCurrentXScale(this.view.lowerSubView); + this.axes.createYAxis(this.lowerLineData, yScale); + this._plotUpdate(this.lowerLineData, xScaleLower, yScale); + } + } + + /** + * @private + * Plot the lines. + * + * @param {D3LineData} lineData The line data + * @param {Array} seriesEnter + */ + _plotLine(lineData, seriesEnterEls) { + let xScale = this._getCurrentXScale(lineData.subView); + let yScale = this._getCurrentYScale(lineData.subView); + let line = this.axes.line(lineData, xScale, yScale); + + d3.selectAll(seriesEnterEls) + .append('path') + .each(( + /** @type {D3LineSeriesData} */ series, + /** @type {Number} */ i, + /** @type {Array} */ els) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + Preconditions.checkStateInteger(i); + Preconditions.checkStateArrayInstanceOf(els, SVGElement); + this._plotLineSeries(series, els[i], line); + }); + } + + /** + * @private + * Add a D3LineSeries line to the plot. + * + * @param {D3LineSeriesData} series The series to add + * @param {SVGElement} dataEl The plot data element + * @param {Function} line The line function + */ + _plotLineSeries(series, dataEl, line) { + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOfSVGElement(dataEl); + Preconditions.checkArgumentInstanceOf(line, Function); + + d3.select(dataEl) + .classed('plot-line', true) + .attr('d', (/** @type {D3LineSeriesData} */series) => { + return line(series.data); + }) + .attr('stroke-dasharray', (/** @type {D3LineSeriesData} */ series) => { + return series.lineOptions.svgDashArray; + }) + .attr('stroke', (/** @type {D3LineSeriesData} */ series) => { + return series.lineOptions.color; + }) + .attr('stroke-width', (/** @type {D3LineSeriesData} */ series) => { + return series.lineOptions.lineWidth; + }) + .attr('fill', 'none') + .style('shape-rendering', 'geometricPrecision') + .style('cursor', 'pointer'); + } + + /** + * @private + * Add a reference line to the plot. + * + * @param {D3LineData} lineData The upper or lower line data + * @param {D3LineSeriesData} series The series to add + * @param {SVGElement} refLineEl The reference line element + */ + _plotRefLine(lineData, series, refLineEl) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOfSVGElement(refLineEl); + + let xScale = this._getCurrentXScale(lineData.subView); + let yScale = this._getCurrentYScale(lineData.subView); + let line = this.axes.line(lineData, xScale, yScale); + + d3.select(refLineEl) + .datum(series) + .selectAll('path') + .data([ series ]) + .enter() + .append('path') + .each(( + /** @type {D3LineSeriesData} */ series, + /** @type {Number} */ i, + /** @type {Array} */ els) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + Preconditions.checkStateInteger(i); + Preconditions.checkStateArrayInstanceOf(els, SVGElement); + this._plotLineSeries(series, els[i], line); + }); + } + + /** + * @private + * Select the line and symbol. + * + * @param {D3LineData} lineData The line data + * @param {D3LineSeriesData} series The data series + * @param {SVGElement} dataEl The data SVG element + */ + _plotSelection(lineData, series, dataEl) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOfSVGElement(dataEl); + + d3.select(dataEl).raise(); + let isActive = !dataEl.classList.contains('active'); + let lineEls = dataEl.querySelectorAll('.plot-line'); + let symbolEls = dataEl.querySelectorAll('.plot-symbol'); + + d3.select(lineData.subView.svg.dataContainerEl) + .selectAll('.data-enter') + .classed('active', false); + + dataEl.classList.toggle('active', isActive); + D3Utils.linePlotSelection(series, lineEls, symbolEls, isActive); + + let selectionEvent = new CustomEvent( + 'plotSelection', + { detail: series }); + lineData.subView.svg.dataContainerEl.dispatchEvent(selectionEvent); + } + + /** + * @private + * Plot selection event listner. + * + * @param {D3LineData} lineData The line data + */ + _plotSelectionEventListener(lineData) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + + d3.select(lineData.subView.svg.dataContainerEl) + .selectAll('.data-enter') + .filter((/** @type {D3LineSeriesData */ series) => { + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + return series.lineOptions.selectable; + }) + .on('click', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + this.selectLine(series.lineOptions.id, lineData); + }); + } + + /** + * @private + * Plot the symbols. + * + * @param {D3LineData} lineData The line data + * @param {Array} seriesEnter The SVG elements + */ + _plotSymbol(lineData, seriesEnterEls) { + d3.selectAll(seriesEnterEls) + .selectAll('.plot-symbol') + .data((/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return lineData.toMarkerSeries(series); + }) + .enter() + .filter((/** @type {D3LineSeriesData */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return series.data[0].x != null && series.data[0].y != null; + }) + .append('path') + .attr('class', 'plot-symbol') + .each(( + /** @type {D3LineSeriesData} */ series, + /** @type {Number} */ i, + /** @type {Array} */ els) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + Preconditions.checkStateInteger(i); + Preconditions.checkStateArrayInstanceOf(els, SVGElement); + this._plotSymbolSeries(lineData, series, els[i]); + }); + } + + /** + * @private + * Add a D3LineSeries symbols to the plot. + * + * @param {D3LineData} lineData The line data + * @param {D3LineSeriesData} series The series to add + * @param {SVGElement} dataEl The plot data element + */ + _plotSymbolSeries(lineData, series, dataEl) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOfSVGElement(dataEl); + + let xScale = this._getCurrentXScale(lineData.subView); + let yScale = this._getCurrentYScale(lineData.subView); + + d3.select(dataEl) + .attr('d', (/** @type {D3LineSeriesData} */ series) => { + return series.d3Symbol(); + }) + .attr('transform', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + + let x = this.axes.x(lineData, xScale, series.data[0]); + let y = this.axes.y(lineData, yScale, series.data[0]); + let rotate = series.lineOptions.d3SymbolRotate; + + return `translate(${x}, ${y}) rotate(${rotate})`; + }) + .attr('fill', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return series.lineOptions.markerColor; + }) + .attr('stroke', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return series.lineOptions.markerEdgeColor; + }) + .attr('stroke-width', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return series.lineOptions.markerEdgeWidth; + }) + .style('shape-rendering', 'geometricPrecision') + .style('cursor', 'pointer') + .on('mouseover', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + this._onDataSymbolMouseover(lineData, series); + }) + .on('mouseout', () => { + this._onDataSymbolMouseout(lineData); + }); + } + + /** + * @private + * Update the plot + * + * @param {D3LineData} lineData The line data + * @param {String} xScale The current X scale + * @param {String} yScale The current Y scale + */ + _plotUpdate(lineData, xScale, yScale) { + this._plotUpdateHorizontalRefLine(lineData, xScale, yScale); + this._plotUpdateVerticalRefLine(lineData, xScale, yScale); + this._plotUpdateText(lineData, xScale, yScale); + + /* Update lines */ + let line = this.axes.line(lineData, xScale, yScale); + + d3.select(lineData.subView.svg.dataContainerEl) + .selectAll('.data-enter') + .selectAll('.plot-line') + .transition() + .duration(lineData.subView.options.translationDuration) + .attr('d', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return line(series.data); + }); + + /* Update symbols */ + d3.select(lineData.subView.svg.dataContainerEl) + .selectAll('.data-enter') + .selectAll('.plot-symbol') + .transition() + .duration(lineData.subView.options.translationDuration) + .attr('d', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return series.d3Symbol(); + }) + .attr('transform', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + + let x = this.axes.x(lineData, xScale, series.data[0]); + let y = this.axes.y(lineData, yScale, series.data[0]); + let rotate = series.lineOptions.d3SymbolRotate; + + return `translate(${x}, ${y}) rotate(${rotate})`; + }); + } + + /** + * @private + * Update plot for single data element. + * + * @param {D3LineData} lineData The line data + * @param {D3LineSeriesData} series The line series + * @param {SVGElement} dataEl The plot data element + * @param {Number} translationDuration The duration for translation + */ + _plotUpdateDataEl(lineData, series, dataEl, xScale, yScale, translationDuration) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOfSVGElement(dataEl); + + let line = this.axes.line(lineData, xScale, yScale); + + /* Update data */ + d3.select(dataEl).datum(series); + + /* Update line */ + d3.select(dataEl) + .selectAll('.plot-line') + .datum(series) + .transition() + .duration(translationDuration) + .attr('d', (/** @type {D3LineSeriesData*/ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return line(series.data); + }); + + let lineOptions = series.lineOptions; + let symbolSize = lineOptions.d3SymbolSize * lineOptions.selectionMultiplier; + let edgeWidth = lineOptions.markerEdgeWidth * lineOptions.selectionMultiplier; + + /* Update symbols */ + d3.select(dataEl) + .selectAll('.plot-symbol') + .data(() => { + let symbolLineData = D3LineData.builder() + .subView(lineData.subView) + .data( + series.xValues, + series.yValues, + series.lineOptions, + series.xStrings, + series.yStrings) + .build(); + + return symbolLineData.toMarkerSeries(series); + }) + .transition() + .duration(translationDuration) + .attr('d', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return series.d3Symbol.size(symbolSize)(); + }) + .attr('stroke-width', edgeWidth) + .attr('transform', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + let x = this.axes.x(lineData, xScale, series.data[0]); + let y = this.axes.y(lineData, yScale, series.data[0]); + let rotate = series.lineOptions.d3SymbolRotate; + return `translate(${x}, ${y}) rotate(${rotate})`; + }); + } + + /** + * @private + * Update any horizontal reference lines. + * + * @param {D3LineData} lineData The upper or lower line data + * @param {String} xScale The X scale + * @param {String} yScale The Y scale + */ + _plotUpdateHorizontalRefLine(lineData, xScale, yScale) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentString(xScale); + Preconditions.checkArgumentString(yScale); + + d3.select(lineData.subView.svg.dataContainerEl) + .selectAll('.data-enter-ref-line.horizontal-ref-line') + .selectAll('.plot-line') + .each(( + /** @type {D3LineSeriesData */ series, + /** @type {Number} */ i, + /** @type {Array} */ els) => { + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + + let refLineEl = els[i].parentNode; + Preconditions.checkArgumentInstanceOfSVGElement(refLineEl); + + let xDomain = this.axes._getXAxisScale(lineData, xScale).domain(); + + let updatedSeries = new D3LineSeriesData( + xDomain, + series.yValues, + series.lineOptions, + series.xStrings, + series.yStrings); + + let duration = lineData.subView.options.translationDuration; + this._plotUpdateDataEl( + lineData, + updatedSeries, + refLineEl, + xScale, + yScale, + duration); + }); + } + + /** + * @private + * Update any added text. + * + * @param {D3LineData} lineData The line data + * @param {String} xScale The X axis scale + * @param {String} yScale The Y axis scale + */ + _plotUpdateText(lineData, xScale, yScale) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + + d3.select(lineData.subView.svg.dataContainerEl) + .selectAll('.text-enter') + .selectAll('text') + .transition() + .duration(lineData.subView.options.translationDuration) + .attr('x', (/** @type {D3XYPair} */ xyPair) => { + Preconditions.checkStateInstanceOf(xyPair, D3XYPair); + return this.axes.x(lineData, xScale, xyPair); + }) + .attr('y', (/** @type {D3XYPair} */ xyPair) => { + Preconditions.checkStateInstanceOf(xyPair, D3XYPair); + return this.axes.y(lineData, yScale, xyPair); + }); + } + + /** + * @private + * Update any vertical reference lines. + * + * @param {D3LineData} lineData The upper or lower line data + * @param {String} xScale The X scale + * @param {String} yScale The Y scale + */ + _plotUpdateVerticalRefLine(lineData, xScale, yScale) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentString(xScale); + Preconditions.checkArgumentString(yScale); + + d3.select(lineData.subView.svg.dataContainerEl) + .selectAll('.data-enter-ref-line.vertical-ref-line') + .selectAll('.plot-line') + .each(( + /** @type {D3LineSeriesData */ series, + /** @type {Number} */ i, + /** @type {Array} */ els) => { + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + + let yDomain = this.axes._getYAxisScale(lineData, yScale).domain(); + let refLineEl = els[i].parentNode; + Preconditions.checkArgumentInstanceOfSVGElement(refLineEl); + + let updatedSeries = new D3LineSeriesData( + series.xValues, + yDomain, + series.lineOptions, + series.xStrings, + series.yStrings); + + let duration = lineData.subView.options.translationDuration; + this._plotUpdateDataEl( + lineData, + updatedSeries, + refLineEl, + xScale, + yScale, + duration); + }); + } + + /** + * @private + * Reset all plot selections + * + * @param {D3LineData} lineData The line data + */ + _resetPlotSelection(lineData) { + d3.select(lineData.subView.svg.dataContainerEl) + .selectAll('.plot-line') + .attr('stroke-width', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return series.lineOptions.lineWidth; + }); + + d3.select(lineData.subView.svg.dataContainerEl) + .selectAll('.plot-symbol') + .attr('d', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return series.d3Symbol.size(series.lineOptions.d3SymbolSize)(); + }) + .attr('stroke-width', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return series.lineOptions.markerEdgeWidth; + }); + } + + /** + * @private + * Set the current line data. + * + * @param {D3LineData} lineData The line data to set + */ + _setLineData(lineData) { + if (lineData.subView.options.subViewType == 'lower') { + this.lowerLineData = lineData; + } else { + this.upperLineData = lineData; + } + } + +} diff --git a/webapp/apps/js/d3/D3SaveFigure.js b/webapp/apps/js/d3/D3SaveFigure.js new file mode 100644 index 000000000..e89282c4d --- /dev/null +++ b/webapp/apps/js/d3/D3SaveFigure.js @@ -0,0 +1,811 @@ + +import { D3BaseSubView } from './view/D3BaseSubView.js'; +import { D3BaseView } from './view/D3BaseView.js'; +import { D3SaveFigureOptions } from './options/D3SaveFigureOptions.js'; +import { D3XYPair } from './data/D3XYPair.js'; + +import NshmpError from '../error/NshmpError.js'; +import { Preconditions } from '../error/Preconditions.js'; + +/** + * @fileoverview Preview and/or save a view's figures to: + * - JPEG + * - PNG + * - SVG + * + * Use D3SaveFigure.save, D3SaveFigure.saveImageOnly, + * D3SaveFigure.preview, and D3SaveFigure.previewImageOnly + * to save and preview the figures. + * + * @class D3SaveFigure + * @author Brandon Clayton + */ +export class D3SaveFigure { + + /** + * @private + * Use either: + * - D3SaveFigure.save + * - D3SaveFigure.preview + * + * @param {D3BaseView} view The view + * @param {D3BaseSubView} subView The sub view + * @param {String} format The save format: 'jpeg' || 'png' || 'svg' + * @param {Boolean} saveFigure Whether to save the figure + * @param {Boolean} imageOnly Whether to save or preview just the image + * (page size is image size) + */ + constructor(view, subView, format, saveFigure, imageOnly) { + Preconditions.checkArgumentInstanceOf(view, D3BaseView); + Preconditions.checkArgumentInstanceOf(subView, D3BaseSubView); + Preconditions.checkArgument( + format == 'jpeg' || format == 'png' || format == 'svg', + `Format [${format}] not supported`); + Preconditions.checkArgumentBoolean(saveFigure); + + /** @type {D3BaseView} */ + this.view = view; + + /** @type {D3BaseSubView} */ + this.subView = subView; + + /** @type {Boolean} */ + this.imageOnly = imageOnly; + + /** @type {String} */ + this.saveFormat = format; + + /** @type {D3SaveFigureOptions} */ + this.options = this.subView.options.saveFigureOptions; + + /** @type {String} */ + this.filename = this.subView.options.filename; + + /** @type {Number} */ + this.baseDPI = 96; + + /** @type {Number} */ + this.printDPI = format == 'svg' ? this.baseDPI : this.options.dpi; + + let marginTopBasePx = this.options.marginTop * this.baseDPI; + let dpiRatio = this.printDPI / this.baseDPI; + + /** @type {Number} */ + this.pageHeightPxPrintDPI = this.imageOnly ? + ( this.subView.options.plotHeight + marginTopBasePx ) * dpiRatio: + this.options.pageHeight * this.printDPI; + + /** @type {Number} */ + this.pageWidthPxPrintDPI = this.imageOnly ? + this.subView.options.plotWidth * dpiRatio : + this.options.pageWidth * this.printDPI; + + /** @type {Number} */ + this.pageHeightPxBaseDPI = this.imageOnly ? + this.subView.options.plotHeight + marginTopBasePx : + this.options.pageHeight * this.baseDPI; + + /** @type {Number} */ + this.pageWidthPxBaseDPI = this.imageOnly ? + this.subView.options.plotWidth : + this.options.pageWidth * this.baseDPI; + + /** @type {SVGElement} */ + this.svgEl = this.subView.svg.svgEl.cloneNode(true); + + /** @type {SVGElement} */ + this.svgOuterPlotEl = this.svgEl.querySelector('.outer-plot'); + + /** @type {SVGElement} */ + this.svgOuterPlotFrameEl = this.svgOuterPlotEl.querySelector('.outer-frame'); + + /** @type {SVGElement} */ + this.svgInnerPlotEl = this.svgEl.querySelector('.inner-plot'); + + /** @type {SVGElement} */ + this.svgInnerPlotFrameEl = this.svgInnerPlotEl.querySelector('.inner-frame'); + + /** @type {Number} */ + this.footerHeightInch = 0.5; + + Preconditions.checkStateInstanceOfSVGElement(this.svgEl); + Preconditions.checkStateInstanceOfSVGElement(this.svgInnerPlotEl); + Preconditions.checkStateInstanceOfSVGElement(this.svgInnerPlotFrameEl); + Preconditions.checkStateInstanceOfSVGElement(this.svgOuterPlotEl); + Preconditions.checkStateInstanceOfSVGElement(this.svgOuterPlotFrameEl); + + /** @type {HTMLIFrameElement} */ + this.iFrameEl = document.createElement('iFrame'); + document.body.appendChild(this.iFrameEl); + Preconditions.checkStateInstanceOf(this.iFrameEl, HTMLIFrameElement); + this.iFrameEl.style.visibility = 'hidden'; + + /** @type {HTMLIFrameElement} */ + let iFrameBodyEl = this.iFrameEl.contentWindow.document.body; + Preconditions.checkStateInstanceOf( + iFrameBodyEl, + this.iFrameEl.contentWindow.HTMLElement); + iFrameBodyEl.appendChild(this.svgEl); + + if (saveFigure) { + this._saveFigure(); + } else { + this._previewFigure(); + } + + this.iFrameEl.remove(); + } + + /** + * Preview a view's figures in a new window with a metadata table and a + * footer with a date and the URL. + * + * @param {D3BaseView} view The view with the plot to preview + * @param {String} previewFormat The preview format: 'jpeg' || 'png' || 'svg' + */ + static preview(view, previewFormat) { + Preconditions.checkArgumentInstanceOf(view, D3BaseView); + Preconditions.checkArgumentString(previewFormat); + + D3SaveFigure._create( + view, + previewFormat.toLowerCase(), + false /* Save figure */, + false /* Image only */); + } + + /** + * Preview a view's figures in a new window as just the images. + * + * @param {D3BaseView} view The view with the plot to preview + * @param {String} previewFormat The preview format: 'jpeg' || 'png' || 'svg' + */ + static previewImageOnly(view, previewFormat) { + Preconditions.checkArgumentInstanceOf(view, D3BaseView); + Preconditions.checkArgumentString(previewFormat); + + D3SaveFigure._create( + view, + previewFormat.toLowerCase(), + false /* Save figure */, + true /* Image only */); + } + + /** + * Save a view's figures in a specific format with a metadata table + * and a footer with the date and the URL. + * + * @param {D3BaseView} view The view with the plots + * @param {String} saveFormat The save format: 'jpeg' || 'png' || 'svg' + */ + static save(view, saveFormat) { + Preconditions.checkArgumentInstanceOf(view, D3BaseView); + Preconditions.checkArgumentString(saveFormat); + + D3SaveFigure._create( + view, + saveFormat.toLowerCase(), + true /* Save figure */, + false /* Image only */); + } + + /** + * Save a view's figures in a specific format as just the images. + * + * @param {D3BaseView} view The view with the plots + * @param {String} saveFormat The save format: 'jpeg' || 'png' || 'svg' + */ + static saveImageOnly(view, saveFormat) { + Preconditions.checkArgumentInstanceOf(view, D3BaseView); + Preconditions.checkArgumentString(saveFormat); + + D3SaveFigure._create( + view, + saveFormat.toLowerCase(), + true /* Save figure */, + true /* Image only */); + } + + /** + * @private + * Save or preview both the upper and lower sub view's figures. + * + * @param {D3BaseView} view The view + * @param {String} format The save/preview format: 'jpeg' || 'png' || 'svg' + * @param {Boolean} saveFigure Whether to save the figure + * @param {Boolean} imageOnly Whether to save or preview the image only + * (page size is image size) + */ + static _create(view, format, saveFigure, imageOnly) { + Preconditions.checkArgumentInstanceOf(view, D3BaseView); + Preconditions.checkArgumentString(format); + Preconditions.checkArgumentBoolean(saveFigure); + Preconditions.checkArgumentBoolean(imageOnly); + + format = format.toLowerCase(); + + if (view.addLowerSubView) { + new D3SaveFigure(view, view.lowerSubView, format, saveFigure, imageOnly); + } + + new D3SaveFigure(view, view.upperSubView, format, saveFigure, imageOnly); + } + + /** + * @private + * Add the URL and date to the bottom of the plot. + */ + _addFooter() { + let footerText = [ + window.location.href.toString(), + new Date().toLocaleString(), + ]; + + let footerD3 = d3.select(this.svgEl) + .append('g') + .attr('class', 'print-footer') + .style('font-size', `${this.options.footerFontSize}px`); + + footerD3.selectAll('text') + .data(footerText.reverse()) + .enter() + .append('text') + .attr('y', (/** @type {String} */ text, /** @type {Number} */ i) => { + Preconditions.checkStateString(text); + Preconditions.checkStateInteger(i); + return -this.options.footerLineBreak * i; + }) + .text((/** @type {String} */ text) => { + Preconditions.checkStateString(text); + return text; + }) + + let footerEl = footerD3.node(); + + this._fitFooter(footerEl); + } + + /** + * @private + * Add the metadata table to the page. + * + * @param {D3XYPair} plotTranslate The X and Y translate + */ + _addMetadataTable(plotTranslate) { + if (!this.options.addMetadata) return; + Preconditions.checkArgument(plotTranslate, D3XYPair); + + let tableInfo = this._updateMetadataForTable(); + let maxColumnValues = this.options.metadataMaxColumnValues; + + let foreignD3 = d3.select(this.svgEl) + .select('g') + .append('foreignObject') + .style('overflow', 'visible'); + + let tableD3 = foreignD3.append('xhtml:table') + .attr('xmlns', 'http://www.w3.org/1999/xhtml') + .style('font-family', '"Helvetica Neue",Helvetica,Arial,sans-serif') + .style('font-size', `${this.options.metadataFontSize}px`) + .style('border-collapse', 'collapse') + .style('background', 'white'); + + for (let data of tableInfo.metadataSet) { + let tableRowD3 = tableD3.append('tr'); + + for (let datum of data) { + let key = datum[0]; + let values = datum[1]; + let rowSpan = values.length > 1 ? tableInfo.rows : 1; + + tableRowD3.append('th') + .attr('nowrap', true) + .attr('valign', 'top') + .attr('rowspan', rowSpan) + .style('padding', '4px 4px 4px 8px') + .style('text-align', 'right') + .html(key); + + let innerTableD3 = tableRowD3.append('td') + .attr('rowspan', rowSpan) + .attr('valign', 'top') + .style('padding', '4px 4px') + .append('table') + .style('font-size', `${this.options.metadataFontSize}px`) + .style('border-collapse', 'collapse') + .style('background', 'white'); + + let nValues = values.length; + if (nValues > maxColumnValues) { + values = values.slice(0, maxColumnValues); + let nMore = nValues - maxColumnValues + values.push(`... and ${nMore} others ...`); + } + + for (let value of values) { + value = Number.isNaN(value) || value == null ? '--' : value; + innerTableD3.append('tr') + .append('td') + .attr('nowrap', true) + .style('text-align', 'left') + .text(value); + } + } + } + + let tableEl = tableD3.node(); + Preconditions.checkStateInstanceOf( + tableEl, + this.iFrameEl.contentWindow.HTMLElement); + + let tableDim = tableEl.getBoundingClientRect(); + let tableWidthInPx = tableDim.width; + let tableHeightInPx = tableDim.height; + + this._fitMetadataTable( + foreignD3.node(), + tableHeightInPx, + tableWidthInPx, + plotTranslate); + } + + /** + * @private + * Add the plot title. + * + * @param {D3XYPair} plotTranslate The X and Y translate + */ + _addPlotTitle(plotTranslate) { + if (!this.options.addTitle) return; + + Preconditions.checkArgumentInstanceOf(plotTranslate, D3XYPair); + let titleTranslate = this._titlePosition(plotTranslate); + + d3.select(this.svgInnerPlotEl) + .append('text') + .attr('class', 'plot-title') + .attr('x', titleTranslate.x) + .attr('y', titleTranslate.y) + .attr('text-anchor', () => { + return this.options.titleLocation == 'left' ? 'start' : 'middle'; + }) + .attr('alignment-baseline', 'middle') + .text(this.view.getTitle()); + } + + /** + * @private + * Create the canvas element. + * + * @returns {HTMLCanvasElement} The canvas element + */ + _createCanvas() { + let canvasDivD3 = d3.select('body') + .append('div') + .attr('class', 'svg-to-canvas hidden'); + + let canvasD3 = canvasDivD3.append('canvas') + .attr('height', this.pageHeightPxPrintDPI) + .attr('width', this.pageWidthPxPrintDPI) + .style('height', `${this.options.pageHeight}in`) + .style('width', `${this.options.pageWidth}in`); + + let canvasEl = canvasD3.node(); + Preconditions.checkStateInstanceOf(canvasEl, HTMLCanvasElement); + + canvasDivD3.remove(); + + return canvasEl; + } + + /** + * @private + * Create a new window to preview the image. + * + * @param {String} imageSrc The image source + */ + _createPreviewWindow(imageSrc) { + Preconditions.checkArgumentString(imageSrc); + + let win = window.open('', '_blank') + win.document.title = this.filename; + win.focus(); + + d3.select(win.document.body) + .style('margin', '0 5em') + .style('background', 'black') + .append('img') + .attr('src', imageSrc) + .style('height', 'auto') + .style('width', 'auto') + .style('max-height', '-webkit-fill-available') + .style('max-width', '100%') + .style('display', 'block') + .style('margin', 'auto') + .style('padding', '2em 0') + .style('top', '50%') + .style('transform', 'translate(0, -50%)') + .style('position', 'relative'); + + this._createPreviewDownloadButton(win, imageSrc); + } + + /** + * @private + * Add a download button to the preview window. + * + * @param {Window} win The preview window + * @param {String} imageSrc The image src to download + */ + _createPreviewDownloadButton(win, imageSrc) { + Preconditions.checkStateObjectProperty(win, 'Window'); + Preconditions.checkArgumentInstanceOf(win, win.Window); + Preconditions.checkArgumentString(imageSrc); + + d3.select(win.document.body) + .append('button') + .style('background', 'white') + .style('position', 'absolute') + .style('bottom', '0.5em') + .style('left', '1em') + .style('padding', '0.5em') + .style('font-size', '1em') + .style('border-radius', '4px') + .attr('href', imageSrc) + .text('Download') + .on('click', () => { + new D3SaveFigure( + this.view, + this.subView, + this.saveFormat, + true /* Save figure */, + this.imageOnly); + }); + } + + /** + * @private + * Create the image source string + */ + _createSVGImageSource() { + return `data:image/svg+xml;base64,` + + `${btoa(unescape(encodeURIComponent(this.svgEl.outerHTML)))}`; + } + + /** + * @private + * Draw the canvas + * + * @param {HTMLCanvasElement} canvasEl The canvas element + * @param {HTMLImageElement} svgImage The svg image to draw + */ + _drawCanvas(canvasEl, svgImage) { + Preconditions.checkArgumentInstanceOf(canvasEl, HTMLCanvasElement); + Preconditions.checkArgumentInstanceOf(svgImage, HTMLImageElement); + + let dpiScale = this.printDPI / this.baseDPI; + let canvasContext = canvasEl.getContext('2d'); + + canvasContext.scale(dpiScale, dpiScale); + + canvasContext.fillStyle = 'white'; + + canvasContext.fillRect(0, 0, + this.pageWidthPxPrintDPI, this.pageHeightPxPrintDPI); + + canvasContext.drawImage(svgImage, 0, 0, + this.pageWidthPxPrintDPI, this.pageHeightPxPrintDPI); + } + + /** + * @private + * Scale the footer such that it will fit on the page. + * + * @param {SVGElement} footerEl The footer element to scale + */ + _fitFooter(footerEl) { + Preconditions.checkArgumentInstanceOf( + footerEl, + this.iFrameEl.contentWindow.SVGElement); + + let footerDim = footerEl.getBoundingClientRect(); + let footerWidthInInch = footerDim.width / this.baseDPI; + let footerHeightInInch = footerDim.height / this.baseDPI; + let footerPaddingInInch = this.options.footerPadding / this.baseDPI; + + let pageWidth = this.options.pageWidth - footerPaddingInInch * 2; + + let widthScale = footerWidthInInch > pageWidth ? + pageWidth / footerWidthInInch : 1; + + let footerHeight = this.footerHeightInch - footerPaddingInInch; + let heightScale = footerHeightInInch > footerHeight ? + footerHeight / footerHeightInInch : 1; + + let footerScale = Math.min(heightScale, widthScale); + + let footerPadding = this.options.footerPadding; + let footerTransform = `translate(${footerPadding}, ` + + `${this.pageHeightPxBaseDPI - footerPadding}) scale(${footerScale})`; + + d3.select(footerEl).attr('transform', footerTransform); + } + + /** + * @private + * Scale the metadata table to fit under the figure. + * + * @param {SVGElement} foreignObjectEl The Foriegn Object SVG element + * @param {Number} tableHeightInPx The table height in pixels + * @param {Number} tableWidthInPx The table width in pixels + * @param {D3XYPair} plotTranslate The plot margins + */ + _fitMetadataTable(foreignObjectEl, tableHeightInPx, tableWidthInPx, plotTranslate) { + Preconditions.checkArgumentInstanceOf( + foreignObjectEl, + this.iFrameEl.contentWindow.SVGElement); + Preconditions.checkArgumentNumber(tableHeightInPx); + Preconditions.checkArgumentNumber(tableWidthInPx); + Preconditions.checkArgumentInstanceOf(plotTranslate, D3XYPair); + + let svgHeightInInch = this.subView.options.plotHeight / this.baseDPI; + let tableMarginTopInPx = this.options.metadataMarginTop * this.baseDPI; + + let tableHeightInInch = tableHeightInPx / this.baseDPI; + let tableWidthInInch = tableWidthInPx / this.baseDPI; + + let availableHeight = this.options.pageHeight - + this.options.marginTop - + svgHeightInInch - + this.options.metadataMarginTop - + this.footerHeightInInch; + + let heightScale = tableHeightInInch > availableHeight ? + availableHeight / tableHeightInInch : 1; + + let widthScale = tableWidthInInch > this.options.pageWidth ? + this.options.pageWidth / tableWidthInInch : 1; + + let tableScale = Math.min(heightScale, widthScale); + + let centerTableX = - plotTranslate.x + ( this.pageWidthPxBaseDPI - + ( tableWidthInPx * tableScale )) / 2; + + d3.select(foreignObjectEl) + .attr('transform', `scale(${tableScale})`) + .attr('height', tableHeightInPx) + .attr('width', tableWidthInPx) + .attr('y', (this.subView.options.plotHeight + tableMarginTopInPx) / tableScale) + .attr('x', centerTableX / tableScale); + } + + /** + * @private + * Convert the metadata Map into a set of arrays to be used to create + * the table. + * + * @param {Map} metadata The metadata Map + * @param {Number} rows The number of rows the table will have + * @param {Number} maxColumns The number of key-value columns the table + * will have + * @param {Boolean} expandColumn Whether the first item in the metadata + * Map will expand all rows + * @return {Set>>} The metadata set of arrays + */ + _metadataToSet(metadata, rows, maxColumns, expandColumn) { + Preconditions.checkArgumentInstanceOfMap(metadata); + Preconditions.checkArgumentInteger(rows); + Preconditions.checkArgumentInteger(maxColumns); + Preconditions.checkArgumentBoolean(expandColumn); + + let metadataSet = new Set(); + let tmpArray = Array.from(metadata); + + let iStart = 0; + let iEnd = 0; + for (let jl = 0; jl < rows; jl++) { + iStart = iEnd; + iEnd = iStart + maxColumns; + if (jl == 0 && expandColumn) iEnd++; + metadataSet.add(tmpArray.slice(iStart, iEnd)); + } + + return metadataSet; + } + + /** + * @private + * Preview the figure + */ + _previewFigure() { + let svgImage = new Image(); + this._updateSVGElement(); + + d3.select(this.svgEl) + .attr('height', this.pageHeightPxBaseDPI * this.printDPI) + .attr('width', this.pageWidthPxBaseDPI * this.printDPI) + .style('background', 'white'); + + let svgImageSrc = this._createSVGImageSource(); + let canvasEl = this._createCanvas(); + + svgImage.onload = () => { + this._drawCanvas(canvasEl, svgImage); + + try { + switch (this.saveFormat) { + /* SVG format */ + case 'svg': + this._createPreviewWindow(svgImageSrc); + break; + /* JPEG or PNG format */ + case 'png': + case 'jpeg': + canvasEl.toBlob((blob) => { + let canvasImageSrc = URL.createObjectURL(blob); + this._createPreviewWindow(canvasImageSrc); + }, `image/${this.saveFormat}`, 1.0); + break; + default: + throw new NshmpError(`Plot format [${this.saveFormat}] not supported`); + } + } catch (err) { + d3.select(this.iFrameEl).remove(); + throw new NshmpError(err); + } + }; + + svgImage.setAttribute('src', svgImageSrc); + } + + /** + * @private + * Save the figure + */ + _saveFigure() { + let svgImage = new Image(); + this._updateSVGElement(); + let svgImageSrc = this._createSVGImageSource(); + let canvasEl = this._createCanvas(); + + svgImage.onload = () => { + this._drawCanvas(canvasEl, svgImage); + + let aEl = document.createElement('a'); + aEl.download = this.filename; + + try { + switch (this.saveFormat) { + /* SVG format */ + case 'svg': + aEl.setAttribute('href', svgImage.getAttribute('src')); + aEl.click(); + break; + /* JPEG or PNG format */ + case 'png': + case 'jpeg': + canvasEl.toBlob((blob) => { + aEl.setAttribute('href', URL.createObjectURL(blob)); + aEl.click(); + }, `image/${this.saveFormat}`, 1.0); + break; + default: + throw new NshmpError(`Plot format [${this.saveFormat}] not supported`); + } + } catch (err) { + d3.select(this.iFrameEl).remove(); + throw new NshmpError(err); + } + + }; + + svgImage.setAttribute('src', svgImageSrc); + } + + /** + * @private + * Calculate the translation needed to center the plot in the page. + * + * @returns {D3XYPair} The translations X and Y + */ + _svgTranslate() { + let innerPlotDim = this.svgInnerPlotEl.getBoundingClientRect(); + let innerPlotWidth = innerPlotDim.width; + let marginLeftInPx = ( this.pageWidthPxBaseDPI - innerPlotWidth ) / 2; + let marginTopInPx = this.options.marginTop * this.baseDPI; + + return new D3XYPair(marginLeftInPx, marginTopInPx); + } + + /** + * @private + * Calculate the translation needed for the title. + * + * @param {D3XYPair} plotTranslate The plot translations + * @returns {D3XYPair} The title translation X and Y + */ + _titlePosition(plotTranslate) { + Preconditions.checkArgumentInstanceOf(plotTranslate, D3XYPair); + + let innerPlotWidth = this.svgInnerPlotFrameEl.getBoundingClientRect().width; + let titleX = this.options.titleLocation == 'left' ? 0 : innerPlotWidth / 2; + let titleY = - plotTranslate.y / 2; + + return new D3XYPair(titleX, titleY); + } + + /** + * @private + * Update the metadata Map to put the key with a values array + * greater than 1 in the first position and convert Map to + * a Set. + * + * @typedef {Object} MetadataTableInfo - Parameters to make table. + * @property {Set>>} metadataSet + * Set of metadata + * @property {Number} rows The number of rows in table + * + * @return {MetadataTableInfo} The table parameters + */ + _updateMetadataForTable() { + let metadata = this.view.getMetadata(); + + let maxKey; + let maxValue = []; + + for (let [key, value] of metadata) { + if (value.length > maxValue.length) { + maxKey = key; + maxValue = value; + } + } + + metadata.delete(maxKey); + let reMapped = new Map(); + reMapped.set(maxKey, maxValue); + for (let [key, value] of metadata) { + reMapped.set(key, value); + } + + let expandColumn = maxValue.length > 1; + let maxColumns = expandColumn ? this.options.metadataColumns - 1 : + this.options.metadataColumns; + + let rows = Math.ceil( reMapped.size / maxColumns ); + let metadataSet = this._metadataToSet( + reMapped, + rows, + maxColumns, + expandColumn); + + return { + metadataSet: metadataSet, + rows: rows, + }; + } + + /** + * @private + * Update the SVG element + */ + _updateSVGElement() { + d3.select(this.svgEl) + .attr('viewBox', `0 0 ${this.pageWidthPxPrintDPI} ${this.pageHeightPxPrintDPI}`) + .style('font-family', '"helvetica neue",helvetica,arial,sans-serif') + .attr('height', this.pageHeightPxPrintDPI) + .attr('width', this.pageWidthPxPrintDPI); + + let translate = this._svgTranslate(); + + this.svgOuterPlotEl.setAttribute( + 'transform', + `translate(${translate.x}, ${translate.y})`); + + this._addPlotTitle(translate); + if (!this.imageOnly) { + this._addFooter(); + this._addMetadataTable(translate); + } + } + +} diff --git a/webapp/apps/js/d3/D3SaveLineData.js b/webapp/apps/js/d3/D3SaveLineData.js new file mode 100644 index 000000000..33cad5bc0 --- /dev/null +++ b/webapp/apps/js/d3/D3SaveLineData.js @@ -0,0 +1,79 @@ + +import { D3LineData } from './data/D3LineData.js'; + +import { Preconditions } from '../error/Preconditions.js'; + +/** + * @fileoverview Save D3LineData to a CSV file + * + * Use D3SaveLineData.saveCSV + * + * @class D3SaveLineData + * @author Brandon Clayton + */ +export class D3SaveLineData { + + /** + * @private + * Use D3SaveLineData.saveCSV + * + * @param {String} fileFormat The file format: 'csv' + * @param {...D3LineData} lineDatas The D3LineData(s) + */ + constructor(fileFormat, ...lineDatas) { + Preconditions.checkArgument( + fileFormat == 'csv', + `File format [${fileFormat}] not supported`); + Preconditions.checkArgumentArrayInstanceOf(lineDatas, D3LineData); + + let fileData = []; + + for (let lineData of lineDatas) { + let subViewOptions = lineData.subView.options; + + for (let series of lineData.series) { + fileData.push([ subViewOptions.lineLabel, series.lineOptions.label ]); + let xValues = []; + let yValues = []; + + for (let xyPair of series.data) { + let x = subViewOptions.xValueToExponent ? + xyPair.x.toExponential(subViewOptions.xExponentFractionDigits) : + xyPair.x; + + let y = subViewOptions.yValueToExponent ? + xyPair.y.toExponential(subViewOptions.yExponentFractionDigits) : + xyPair.y; + + xValues.push(xyPair.xString || x); + yValues.push(xyPair.yString || y); + } + + fileData.push([ subViewOptions.xLabel, xValues.join(',') ]); + fileData.push([ subViewOptions.yLabel, yValues.join(',') ]); + fileData.push(''); + } + + let file = new Blob( + [ fileData.join('\n') ], + { type: `text/${fileFormat}` }); + + let aEl = document.createElement('a'); + aEl.download = `${subViewOptions.filename}.${fileFormat}`; + aEl.href = URL.createObjectURL(file); + aEl.click(); + aEl.remove(); + } + } + + /** + * Save D3LineData(s) to CSV files. + * + * @param {...D3LineData} lineDatas The data + */ + static saveCSV(...lineDatas) { + Preconditions.checkArgumentArrayInstanceOf(lineDatas, D3LineData); + new D3SaveLineData('csv', ...lineDatas); + } + +} diff --git a/webapp/apps/js/d3/D3Tooltip.js b/webapp/apps/js/d3/D3Tooltip.js new file mode 100644 index 000000000..13186fca9 --- /dev/null +++ b/webapp/apps/js/d3/D3Tooltip.js @@ -0,0 +1,139 @@ + +import { D3BaseSubView } from './view/D3BaseSubView.js'; + +import { Preconditions } from '../error/Preconditions.js'; + +/** + * @fileoverview Create a tooltip on a D3BaseSubView. + * + * The tooltip is placed automatically to fit in the plot window + * so it will not go past the edge of the plot. + * + * @class D3Tooltip + * @author Brandon Clayton + */ +export class D3Tooltip { + + constructor() {} + + /** + * Create a tooltip on a sub view at a desired X and Y coordinate. + * + * @param {D3BaseSubView} subView The sub view to place the tooltip + * @param {Array} tooltipText The array of text to display + * @param {Number} tooltipX The X coordinate in plot units to + * place the tooltip + * @param {Number} tooltipY The Y coordinate in plot units to + * place the tooltip + */ + create(subView, tooltipText, tooltipX, tooltipY) { + Preconditions.checkArgumentInstanceOf(subView, D3BaseSubView); + Preconditions.checkArgumentArrayOf(tooltipText, 'string'); + Preconditions.checkArgumentNumber(tooltipX); + Preconditions.checkArgumentNumber(tooltipY); + + this._createTooltipTable(subView, tooltipText); + this._setTooltipLocation(subView, tooltipX, tooltipY); + } + + /** + * Remove any tooltip from a sub view + * + * @param {D3BaseSubView} subView The sub view to remove the tooltip + */ + remove(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3BaseSubView); + + d3.select(subView.svg.tooltipTableEl) + .selectAll('*') + .remove(); + + d3.select(subView.svg.tooltipForeignObjectEl) + .attr('height', 0) + .attr('width', 0); + } + + /** + * @private + * Create the tooltip table with tooltip text. + * + * @param {D3BaseSubView} subView The sub view to add a tooltip + * @param {Array} tooltipText The tooltip text + */ + _createTooltipTable(subView, tooltipText) { + Preconditions.checkArgumentInstanceOf(subView, D3BaseSubView); + Preconditions.checkArgumentArrayOf(tooltipText, 'string'); + + let options = subView.options.tooltipOptions; + let padding = `${options.paddingTop}px ${options.paddingRight}px ` + + `${options.paddingBottom}px ${options.paddingLeft}px`; + let borderStyle = `${options.borderLineWidth}px ${options.borderStyle} ` + + `${options.borderColor}`; + + d3.select(subView.svg.tooltipForeignObjectEl) + .attr('height', '100%') + .attr('width', '100%') + + let tableD3 = d3.select(subView.svg.tooltipTableEl) + .style('font-size', `${options.fontSize}px`) + .style('border-collapse', 'separate') + .style('border', borderStyle) + .style('border-radius', `${options.borderRadius}px`) + .style('box-shadow', '0 1px 1px rgba(0, 0, 0, 0.05)') + .style('padding', padding) + .style('background', options.backgroundColor); + + tableD3.selectAll('tr') + .data(tooltipText) + .enter() + .append('tr') + .append('td') + .attr('nowrap', true) + .text((/** @type {String} */ text) => { return text; }); + + d3.select(subView.svg.tooltipEl).raise(); + } + + /** + * @private + * Set the tooltip location, making sure it does not go over the + * edge of the plot. + * + * @param {D3BaseSubView} subView The sub view + * @param {Number} tooltipX The X location of tooltip + * @param {Number} tooltipY The Y location of tooltip + */ + _setTooltipLocation(subView, tooltipX, tooltipY) { + Preconditions.checkArgumentInstanceOf(subView, D3BaseSubView); + Preconditions.checkArgumentNumber(tooltipX); + Preconditions.checkArgumentNumber(tooltipY); + + let foreignObjectEl = subView.svg.tooltipForeignObjectEl; + let tableEl = subView.svg.tooltipTableEl; + + let tooltipHeight = parseFloat(d3.select(tableEl).style('height')); + let tooltipWidth = parseFloat(d3.select(tableEl).style('width')); + + let plotHeight = subView.plotHeight; + let plotWidth = subView.plotWidth; + + let offsetX = subView.options.tooltipOptions.offsetX; + let offsetY = subView.options.tooltipOptions.offsetY; + + let availableWidth = plotWidth - tooltipX; + let xTranslate = ( tooltipWidth + offsetX ) > availableWidth ? + tooltipX - tooltipWidth - offsetX + availableWidth : + tooltipX + offsetX; + + let availableHeight = plotHeight - tooltipY; + let yTranslate = ( tooltipHeight + offsetY ) > availableHeight ? + tooltipY - tooltipHeight - offsetY : + tooltipY + offsetY; + + d3.select(foreignObjectEl) + .attr('height', tooltipHeight) + .attr('width', tooltipWidth) + .attr('transform', `translate(${xTranslate}, ${yTranslate})`); + } + +} diff --git a/webapp/apps/js/d3/D3Utils.js b/webapp/apps/js/d3/D3Utils.js new file mode 100644 index 000000000..31f0d91b6 --- /dev/null +++ b/webapp/apps/js/d3/D3Utils.js @@ -0,0 +1,71 @@ + +import { D3LineData } from './data/D3LineData.js'; +import { D3LineSeriesData } from './data/D3LineSeriesData.js'; + +import { Preconditions } from '../error/Preconditions.js'; + +/** + * @fileoverview D3 Utilities + * + * @class D3Utils + * @author Brandon Clayton + */ +export class D3Utils { + + /** @private */ + constructor() {} + + /** + * Check an array to see if if each value is a number or null. + * + * @param {Array} values Values to check + */ + static checkArrayIsNumberOrNull(values) { + Preconditions.checkArgumentArray(values); + + for (let val of values) { + Preconditions.checkState( + typeof val == 'number' || val === null, + `Value [${val}] must be a number or null`); + } + } + + /** + * Increase/decrease the line width, marker size, and marker edge width + * of all lines and symbols. + * + * @param {D3LineSeriesData} series The data series + * @param {NodeList} lineEls The SVG elements of the lines + * @param {NodeList} symbolEls The SVG elements of the symbols + * @param {Boolean} isActive Whether the line/symbols have been selected + * or deselected + */ + static linePlotSelection(series, lineEls, symbolEls, isActive) { + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOf(lineEls, NodeList); + Preconditions.checkStateInstanceOf(symbolEls, NodeList); + Preconditions.checkArgumentBoolean(isActive); + + let options = series.lineOptions; + + let lineWidth = isActive ? + options.lineWidth * options.selectionMultiplier : + options.lineWidth; + + let symbolSize = isActive ? + options.d3SymbolSize * options.selectionMultiplier : + options.d3SymbolSize; + + let edgeWidth = isActive ? + options.markerEdgeWidth * options.selectionMultiplier : + options.markerEdgeWidth; + + d3.selectAll(lineEls) + .attr('stroke-width', lineWidth); + + d3.selectAll(symbolEls) + .attr('d', series.d3Symbol.size(symbolSize)()) + .attr('stroke-width', edgeWidth); + } + +} diff --git a/webapp/apps/js/d3/axes/D3LineAxes.js b/webapp/apps/js/d3/axes/D3LineAxes.js new file mode 100644 index 000000000..b09401935 --- /dev/null +++ b/webapp/apps/js/d3/axes/D3LineAxes.js @@ -0,0 +1,418 @@ + +import { D3LineData } from '../data/D3LineData.js'; +import { D3LineSubView } from '../view/D3LineSubView.js'; +import { D3LineView } from '../view/D3LineView.js'; +import { D3XYPair } from '../data/D3XYPair.js'; + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview Add X and Y axes, axes labels, and gridlines to + * a D3LinePlot. + * + * @class D3LineAxes + * @author Brandon Clayton + */ +export class D3LineAxes { + + /** + * New instance of D3LineAxes + * + * @param {D3LineView} view The line view + */ + constructor(view) { + Preconditions.checkArgumentInstanceOf(view, D3LineView); + + /** @type {D3LineView} */ + this.view = view; + } + + /** + * Add a log or linear X axis to a D3LineSubView with + * a X label and grid lines. + * + * @param {D3LineData} lineData The line data + * @param {String} scale The scale: 'log' || 'linear' + */ + createXAxis(lineData, scale) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + this._checkScale(scale); + + let subView = lineData.subView; + let translate = subView.options.xAxisLocation == 'top' ? 0 : + subView.plotHeight; + + d3.select(subView.svg.xAxisEl) + .attr('transform', `translate(-0.5, ${translate - 0.5})`) + .style(subView.options.tickFontSize); + + d3.select(subView.svg.xTickMarksEl) + .call(this._getXAxis(lineData, scale)) + .each(() => { + this._setExponentTickMarks(subView, subView.svg.xTickMarksEl, scale); + }); + + this.createXGridLines(lineData, scale); + this._addXLabel(subView); + } + + /** + * Add log or linear X grid lines to a D3LineSubView. + * + * @param {D3LineData} lineData The line data + * @param {String} scale The scale: 'log' || 'linear' + */ + createXGridLines(lineData, scale) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + this._checkScale(scale); + + if (!this.view.viewHeader.gridLinesCheckEl.getAttribute('checked')) return; + + let subView = lineData.subView; + this.removeXGridLines(subView); + + let xGridLines = this._getXAxis(lineData, scale); + xGridLines.tickFormat('') + .tickSize(-subView.plotHeight); + + let xGridD3 = d3.select(subView.svg.xGridLinesEl) + .attr('transform', d3.select(subView.svg.xAxisEl).attr('transform')) + .call(xGridLines); + + xGridD3.selectAll('*') + .attr('stroke', subView.options.gridLineColor) + .attr('stroke-width', subView.options.gridLineWidth); + + xGridD3.selectAll('text') + .remove(); + } + + /** + * Add a log or linear Y axis to a D3LineSubView with + * a Y label and grid lines. + * + * @param {D3LineData} lineData The line data + * @param {String} scale The scale: 'log' || 'linear' + */ + createYAxis(lineData, scale) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + this._checkScale(scale); + + let subView = lineData.subView; + let translate = subView.options.yAxisLocation == 'right' ? + subView.plotWidth : 0; + + d3.select(subView.svg.yAxisEl) + .attr('transform', `translate(${translate - 0.5}, -0.5)`) + .style(subView.options.tickFontSize); + + d3.select(subView.svg.yTickMarksEl) + .call(this._getYAxis(lineData, scale)) + .each(() => { + this._setExponentTickMarks(subView, subView.svg.yTickMarksEl, scale); + }); + + this.createYGridLines(lineData, scale); + this._addYLabel(subView); + } + + /** + * Add log or linear Y grid lines to a D3LineSubView. + * + * @param {D3LineData} lineData The line data + * @param {String} scale The scale: 'log' || 'linear' + */ + createYGridLines(lineData, scale) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + this._checkScale(scale); + + if (!this.view.viewHeader.gridLinesCheckEl.getAttribute('checked')) return; + + let subView = lineData.subView; + this.removeYGridLines(subView); + + let yGridLines = this._getYAxis(lineData, scale); + yGridLines.tickFormat('') + .tickSize(-subView.plotWidth); + + let yGridD3 = d3.select(subView.svg.yGridLinesEl) + .attr('transform', d3.select(subView.svg.yAxisEl).attr('transform')) + .call(yGridLines); + + yGridD3.selectAll('*') + .attr('stroke', subView.options.gridLineColor) + .attr('stroke-width', subView.options.gridLineWidth); + + yGridD3.selectAll('text') + .remove(); + } + + /** + * Returns a D3 line generator. + * + * @param {D3LineData} lineData The D3LineData + * @param {String} xScale The X axis scale + * @param {String} yScale The Y axis scale + * @returns {Function} The line generator + */ + line(lineData, xScale, yScale) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + this._checkScale(xScale); + this._checkScale(yScale); + + let line = d3.line() + .defined((/** @type {D3XYPair} */ d) => { + return d.y != null; + }) + .x((/** @type {D3XYPair} */ d) => { + return this.x(lineData, xScale, d); + }) + .y((/** @type {D3XYPair} */ d) => { + return this.y(lineData, yScale, d); + }) + + return line; + } + + /** + * Remove the X axis grid lines. + * + * @param {D3LineSubView} subView The sub view to remove them from + */ + removeXGridLines(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + d3.select(subView.svg.xGridLinesEl) + .selectAll('*') + .remove(); + } + + /** + * Remove the Y axis grid lines. + * + * @param {D3LineSubView} subView The sub view to remove them from + */ + removeYGridLines(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + d3.select(subView.svg.yGridLinesEl) + .selectAll('*') + .remove(); + } + + /** + * Get the plotting X coordinate of a data point assosciated with a + * D3LineData + * + * @param {D3LineData} lineData The D3LineData for the X coordinate + * @param {String} scale The X axis scale + * @param {D3XYPair} xyPair The data point to plot + * @returns {Number} The plotting X coordinate of the X data point + */ + x(lineData, scale, xyPair) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + this._checkScale(scale); + Preconditions.checkArgumentInstanceOf(xyPair, D3XYPair); + + let d3Scale = this._getXAxisScale(lineData, scale); + return d3Scale(xyPair.x); + } + + /** + * Get the plotting Y coordinate of a data point assosciated with a + * D3LineData + * + * @param {D3LineData} lineData The D3LineData for the Y coordinate + * @param {String} scale The Y axis scale + * @param {D3XYPair} xyPair The data point to plot + * @returns {Number} The plotting Y coordinate of the Y data point + */ + y(lineData, scale, xyPair) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + this._checkScale(scale); + Preconditions.checkArgumentInstanceOf(xyPair, D3XYPair); + + let d3Scale = this._getYAxisScale(lineData, scale); + return d3Scale(xyPair.y); + } + + /** + * @private + * Add a X axis label. + * + * @param {D3LineSubView} subView Sub view to add X label + */ + _addXLabel(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + let y = subView.options.xAxisLocation == 'top' ? + -subView.options.paddingTop : subView.options.paddingBottom; + + d3.select(subView.svg.xLabelEl) + .attr('x', subView.plotWidth / 2) + .attr('y', y) + .style('text-anchor', 'middle') + .style('alignment-baseline', 'middle') + .style('font-weight', subView.options.axisLabelFontWeight) + .text(subView.options.xLabel); + } + + /** + * @private + * Add a Y axis label. + * + * @param {D3LineSubView} subView Sub view to add Y label + */ + _addYLabel(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + let y = subView.options.yAxisLocation == 'right' ? + subView.options.paddingRight : -subView.options.paddingLeft; + + d3.select(subView.svg.yLabelEl) + .attr('x', -subView.plotHeight / 2) + .attr('y', y) + .style('text-anchor', 'middle') + .style('alignment-baseline', 'middle') + .style('font-weight', subView.options.axisLabelFontWeight) + .text(subView.options.yLabel); + } + + /** + * @private + * Check that the scale is either 'log' || 'linear' + * + * @param {String} scale The scale + */ + _checkScale(scale) { + Preconditions.checkArgument( + scale == 'log' || scale == 'linear', + `Axis scale [${scale}] not supported`); + } + + /** + * @private + * Get the D3 axis scale: d3.scaleLog() || d3.scaleLinear() + * + * @param {String} scale The axis scale: 'log' || 'linear' + */ + _getD3AxisScale(scale) { + this._checkScale(scale); + return scale == 'log' ? d3.scaleLog() : d3.scaleLinear(); + } + + /** + * @private + * Get the D3 X axis: d3.axisTop() || d3.axisBottom() + * + * @param {D3LineData} lineData The line data + * @param {String} scale The axis scale: 'log' || 'linear' + */ + _getXAxis(lineData, scale) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + this._checkScale(scale); + + let d3Scale = this._getXAxisScale(lineData, scale); + let axis = lineData.subView.options.xAxisLocation == 'top' ? + d3.axisTop(d3Scale) : d3.axisBottom(d3Scale); + + axis.ticks(lineData.subView.options.xTickMarks); + + return axis; + } + + /** + * @private + * Get the X axis scale and set the range and domain. + * + * @param {D3LineData} lineData The line data + * @param {String} scale The axis scale + */ + _getXAxisScale(lineData, scale) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + this._checkScale(scale); + + let d3Scale = this._getD3AxisScale(scale); + d3Scale.range([ 0, lineData.subView.plotWidth ]) + .domain(lineData.getXLimit()); + + if (lineData.subView.options.xAxisNice) { + d3Scale.nice(lineData.subView.options.xTickMarks); + } + + return d3Scale; + } + + /** + * @private + * Get the Y axis: d3AxisLeft() || d3.axisRight() + * + * @param {D3LineData} lineData The line data + * @param {String} scale The axis scale + */ + _getYAxis(lineData, scale) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + this._checkScale(scale); + + let d3Scale = this._getYAxisScale(lineData, scale); + let axis = lineData.subView.options.yAxisLocation == 'right' ? + d3.axisRight(d3Scale) : d3.axisLeft(d3Scale); + + axis.ticks(lineData.subView.options.yTickMarks); + + return axis; + } + + /** + * @private + * Get the Y axis scale and set the range and domain. + * + * @param {D3LineData} lineData The line data + * @param {String} scale The axis scale + */ + _getYAxisScale(lineData, scale) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + this._checkScale(scale); + + let d3Scale = this._getD3AxisScale(scale); + d3Scale.range([ lineData.subView.plotHeight, 0 ]) + .domain(lineData.getYLimit()); + + if (lineData.subView.options.yAxisReverse) { + d3Scale.range([ 0, lineData.subView.plotHeight ]); + } + + if (lineData.subView.options.yAxisNice) { + d3Scale.nice(lineData.subView.options.yTickMarks); + } + + return d3Scale; + } + + /** + * If the axis scale is 'log' set the tick marks to be + * in exponential form. + * + * @param {D3LineSubView} subView The sub view to set the tick marks + * @param {HTMLElement} tickMarksEl The X or Y tick mark element + * @param {String} scale The axis scale + */ + _setExponentTickMarks(subView, tickMarksEl, scale) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentInstanceOfSVGElement(tickMarksEl); + this._checkScale(scale); + + if (scale != 'log') return; + + d3.select(tickMarksEl) + .selectAll('.tick text') + .text(null) + .filter((d) => { return Number.isInteger(Math.log10(d)); }) + .text(10) + .append('tspan') + .text((d) => { return Math.round(Math.log10(d)); }) + .style('baseline-shift', 'super') + .attr('font-size', subView.options.tickExponentFontSize); + } + +} diff --git a/webapp/apps/js/d3/data/D3LineData.js b/webapp/apps/js/d3/data/D3LineData.js new file mode 100644 index 000000000..d7bf89b24 --- /dev/null +++ b/webapp/apps/js/d3/data/D3LineData.js @@ -0,0 +1,544 @@ + +import { D3LineOptions } from '../options/D3LineOptions.js'; +import { D3LineSeriesData } from './D3LineSeriesData.js'; +import { D3LineSubView } from '../view/D3LineSubView.js'; +import { D3Utils } from '../D3Utils.js'; +import { D3XYPair } from './D3XYPair.js'; + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview Create the data series for line plots. + * + * Use D3LineData.Builder to build a D3LineData instance. + * See D3LineData.builder() + * See D3LineDataBuilder + * + * @class D3LineData + * @author Brandon Clayton + */ +export class D3LineData { + + /** + * @private + * Must use D3LineData.builder() + * + * @param {D3LineDataBuilder} builder The builder + */ + constructor(builder) { + Preconditions.checkArgumentInstanceOf(builder, D3LineDataBuilder); + + /** + * The color scheme for plotting. + * The color scheme will rotate through the colors once the + * length of data is greater than color scheme array. + * Default: d3.schemeCategory10 + * @type {Array} + */ + this.colorScheme = builder._colorScheme; + + /** + * The series XY values and line options. + * @type {Array} + */ + this.series = builder._series; + + /** + * Which line sub view to plot. + * @type {D3LineSubView} + */ + this.subView = builder._subView; + + /** + * The lower and upper limit of the X axis. + * Default: 'auto' + * @type {Array} + */ + this.xLimit = builder._xLimit; + + /** + * The lower and upper limit of the Y axis. + * Default: 'auto' + * @type {Array} + */ + this.yLimit = builder._yLimit; + + this._updateLineOptions(); + + /* Make immutable */ + Object.freeze(this); + } + + /** + * Return a new D3LineDataBuilder. + * See D3LineDataBuilder + * + * @return {D3LineDataBuilder} new D3LineDataBuilder + */ + static builder() { + return new D3LineDataBuilder(); + } + + /** + * Create a new D3LineData from multiple D3LineData. + * + * @param {...D3LineData} lineData + */ + static of(...lineData) { + let builder = D3LineData.builder().of(...lineData); + return builder.build(); + } + + /** + * Combine two D3LineData using the D3LineData.series.lineOptions.id + * field to find matching D3LineSeries. + * + * @param {D3LineData} lineData The line data to combine + */ + concat(lineData) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + + let builder = D3LineData.builder() + .subView(this.subView); + + for (let series of this.series) { + let matchingSeriesArray = lineData.series.filter((seriesConcat) => { + return series.lineOptions.id == seriesConcat.lineOptions.id; + }); + + let xValues = series.xValues; + let yValues = series.yValues; + let xStrings = series.xStrings; + let yStrings = series.yStrings; + + for (let matchingSeries of matchingSeriesArray) { + xValues = xValues.concat(matchingSeries.xValues); + yValues = yValues.concat(matchingSeries.yValues); + + xStrings = xStrings.concat(matchingSeries.xStrings); + yStrings = yStrings.concat(matchingSeries.yStrings); + } + + builder.data( + xValues, + yValues, + series.lineOptions, + xStrings, + yStrings); + } + + return builder.build(); + } + + /** + * Get all XY values. + * + * @returns {Array>} Array of XY values + */ + getData() { + let data = []; + for (let d of this.series) { + data.push(d.data); + } + + return data; + } + + /** + * Get all the line options associated with the XY values. + * + * @returns {Array} Array of line options. + */ + getLineOptions() { + let options = []; + for (let d of this.series) { + options.push(d.lineOptions); + } + + return options; + } + + /** + * Get the X limits for the X axis, either from the set xLimit in + * the builder or from the min and max values in the data. + * + * @returns {Array} The [ min, max ] X values + */ + getXLimit() { + if (this.xLimit) return this.xLimit; + + let max = this._getXLimitMax(); + let min = this._getXLimitMin(); + + return [min, max]; + } + + /** + * Get the Y limits for the Y axis, either from the set yLimit in + * the builder or from the min and max values in the data. + * + * @returns {Array} The [ min, max ] Y values + */ + getYLimit() { + if (this.yLimit) return this.yLimit; + + let max = this._getYLimitMax(); + let min = this._getYLimitMin(); + + return [min, max]; + } + + /** + * Convert a D3LineSeriesData into an + * Array where each D3LineSeriesData is a single + * XY data point. + * + * @param {D3LineSeriesData} series The series to expand + * @returns {Array} The new array of D3LineSeriesData + */ + toMarkerSeries(series) { + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + + let markerSeries = []; + for (let i in series.data) { + let data = series.data[i]; + + markerSeries.push(new D3LineSeriesData( + [data.x], + [data.y], + series.lineOptions, + [series.xStrings[i]], + [series.yStrings[i]])); + } + + return markerSeries; + } + + /** + * Return an Array with only + * D3LineOptions.showLegend as true. + * + * @returns {Array} + */ + toLegendSeries() { + let legendSeries = []; + for (let series of this.series) { + if (!series.lineOptions.showInLegend) continue; + legendSeries.push(series); + } + + return legendSeries; + } + + /** + * @private + * Get the max X value. + */ + _getXLimitMax() { + let max = d3.max(this.series, (/** @type {D3LineSeriesData} */ series) => { + return d3.max(series.data, (/** @type {D3XYPair */ data) => { + return data.x; + }); + }); + + return max; + } + + /** + * @private + * Get the min X value. + */ + _getXLimitMin() { + let min = d3.min(this.series, (/** @type {D3LineSeriesData} */ series) => { + return d3.min(series.data, (/** @type {D3XYPair} */ data) => { + return data.x; + }); + }); + + return min; + } + + /** + * @private + * Get the max Y value. + */ + _getYLimitMax() { + let max = d3.max(this.series, (/** @type {D3LineSeriesData} */ series) => { + return d3.max(series.data, (/** @type {D3XYPair} */ data) => { + return data.y; + }); + }); + + return max; + } + + /** + * @private + * Get the min Y value. + */ + _getYLimitMin() { + let min = d3.min(this.series, (/** @type {D3LineSeriesData} */ series) => { + return d3.min(series.data, (/** @type {D3XYPair} */ data) => { + return data.y; + }); + }); + + return min; + } + + /** @private */ + _updateLineOptions() { + let index = -1; + let colorIndex = -1; + + for (let data of this.series) { + index++; + let color = data.lineOptions.color || this.colorScheme[++colorIndex]; + let id = data.lineOptions.id || `id${index}`; + let label = data.lineOptions.label || `Line ${index}`; + let markerColor = data.lineOptions.markerColor || this.colorScheme[colorIndex]; + markerColor = markerColor == undefined ? color : markerColor; + let markerEdgeColor = data.lineOptions.markerEdgeColor || + this.colorScheme[colorIndex]; + markerEdgeColor = markerEdgeColor == undefined ? color : markerEdgeColor; + + data.lineOptions = D3LineOptions.builder().fromCopy(data.lineOptions) + .color(color) + .id(id) + .label(label) + .markerColor(markerColor) + .markerEdgeColor(markerEdgeColor) + .build(); + } + + } + +} + +/** + * @fileoverview Builder for D3LineData + * + * Use D3LineData.builder() for new instance of D3LineDataBuilder + * + * @class D3LineDataBuilder + * @author Brandon Clayton + */ +export class D3LineDataBuilder { + + /** @private */ + constructor() { + /** @type {Array} */ + this._colorScheme = undefined; + + /** @type {Boolean} */ + this._removeSmallValues = false; + + /** @type {Array} */ + this._series = []; + + /** @type {D3LineSubView} */ + this._subView = undefined; + + /** @type {Array} */ + this._xLimit = undefined; + + /** @type {Array} */ + this._yLimit = undefined; + + /** @type {Number} */ + this._yMinCutOff = undefined; + } + + /** + * Return a new D3Data instance. + * + * @return {D3LineData} new D3Data + */ + build() { + Preconditions.checkNotNull(this._subView, 'Must set subView'); + Preconditions.checkNotUndefined(this._subView, 'Must set subView'); + + this._colorScheme = this._updateColorScheme(); + + this._series = this._series.filter((series) => { + return !series.checkXValuesNull(); + }); + + this._series = this._series.filter((series) => { + return !series.checkYValuesNull(); + }); + + if (this._removeSmallValues) { + for (let series of this._series) { + series.removeSmallValues(this._yMinCutOff); + } + } + + return new D3LineData(this); + } + + /** + * Set the color scheme. + * The color scheme will rotate through the colors once the + * length of data is greater than color scheme array. + * + * @param {Array} scheme Array of colors + */ + colorScheme(scheme) { + Preconditions.checkArgumentArrayOf(scheme, 'string'); + this._colorScheme = scheme; + return this; + } + + /** + * Set x-values, y-values, and line options. + * + * @param {Array} xValues The X values of the data + * @param {Array} yValues The Y values of the data + * @param {D3LineOptions} [lineOptions = D3LineOptions.withDefaults()] + * The line options for the data + * @param {Array} xStrings + * @param {Array} yStrings + */ + data( + xValues, + yValues, + lineOptions = D3LineOptions.withDefaults(), + xStrings = undefined, + yStrings = undefined) { + Preconditions.checkArgumentArray(xValues); + Preconditions.checkArgumentArray(yValues); + + Preconditions.checkArgument( + xValues.length == yValues.length, + 'Arrays must have same length'); + + Preconditions.checkArgumentInstanceOf(lineOptions, D3LineOptions); + + D3Utils.checkArrayIsNumberOrNull(xValues); + D3Utils.checkArrayIsNumberOrNull(yValues); + + let seriesData = new D3LineSeriesData( + xValues, + yValues, + lineOptions, + xStrings, + yStrings); + + this._series.push(seriesData); + return this; + } + + /** + * Create a D3LineDataBuilder from multiple D3LineData. + * + * @param {...D3LineData} lineData + */ + of(...lineData) { + let color = []; + let xLims = []; + let yLims = []; + let subViewType = lineData[0].subView.options.subViewType; + + for (let data of lineData) { + Preconditions.checkArgumentInstanceOf(data, D3LineData); + Preconditions.checkState( + data.subView.options.subViewType == subViewType, + 'Must all be same sub view type'); + + this._series.push(...data.series); + color = color.concat(data.colorScheme); + xLims.push(data.getXLimit()); + yLims.push(data.getYLimit()); + } + + let xMin = d3.min(xLims, (x) => { return x[0]; }); + let xMax = d3.max(xLims, (x) => { return x[1]; }); + + let yMin = d3.min(yLims, (y) => { return y[0]; }); + let yMax = d3.max(yLims, (y) => { return y[1]; }); + + if (color && color.length > 0) { + this.colorScheme(color); + } + + this.subView(lineData[0].subView); + this.xLimit([xMin, xMax]); + this.yLimit([yMin, yMax]); + this._removeSmallValues = false; + + return this; + } + + /** + * Remove all values under a cut off Y value. + * + * @param {Number} yMinCutOff The cut off value + */ + removeSmallValues(yMinCutOff) { + Preconditions.checkArgumentNumber(yMinCutOff); + this._yMinCutOff = yMinCutOff; + this._removeSmallValues = true; + return this; + } + + /** + * Set the line sub view for which to plot the data. + * + * @param {D3LineSubView} view The sub view to plot the data + */ + subView(view) { + Preconditions.checkArgumentInstanceOf(view, D3LineSubView); + this._subView = view; + return this; + } + + /** + * Set the X limits for the X axis. + * Default: 'auto' + * + * @param {Array} lim The X axis limits: [ xMin, xMax ] + */ + xLimit(lim) { + Preconditions.checkArgumentArrayLength(lim, 2); + Preconditions.checkArgumentArrayOf(lim, 'number'); + Preconditions.checkArgument(lim[1] >= lim[0], 'xMax must be greater than xMin'); + + this._xLimit = lim; + return this; + } + + /** + * Set the Y limits for the Y axis. + * Default: 'auto' + * + * @param {Array} lim The Y axis limits: [ yMin, yMax ] + */ + yLimit(lim) { + Preconditions.checkArgumentArrayLength(lim, 2); + Preconditions.checkArgumentArrayOf(lim, 'number'); + Preconditions.checkArgument(lim[1] >= lim[0], 'yMax must be greater than yMin'); + + this._yLimit = lim; + return this; + } + + /** @private */ + _updateColorScheme() { + let nSeries = this._series.length; + + let colors = this._colorScheme ? + this._colorScheme : d3.schemeCategory10; + + let nColors = colors.length; + + let nCat = Math.ceil(nSeries / nColors); + + for (let index = 0; index < nCat; index++) { + colors = colors.concat(colors); + } + + return colors.length > nSeries ? colors.slice(0, nSeries) : colors; + } + +} diff --git a/webapp/apps/js/d3/data/D3LineSeriesData.js b/webapp/apps/js/d3/data/D3LineSeriesData.js new file mode 100644 index 000000000..6bdc9b9cf --- /dev/null +++ b/webapp/apps/js/d3/data/D3LineSeriesData.js @@ -0,0 +1,171 @@ + +import { D3LineOptions } from '../options/D3LineOptions.js'; +import { D3Utils } from '../D3Utils.js'; +import { D3XYPair } from './D3XYPair.js'; + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview Container class to hold XY values and assoiciated + * D3LineOptions + * + * @class D3LineSeriesData + * @author Brandon Clayton + */ +export class D3LineSeriesData { + + /** + * @param {Array} xValues The X values + * @param {Array} yValues The Y values + * @param {D3LineOptions} options The line options + */ + constructor(xValues, yValues, options, xStrings = undefined, yStrings = undefined) { + Preconditions.checkArgumentArray(xValues); + Preconditions.checkArgumentArray(yValues); + Preconditions.checkArgumentInstanceOf(options, D3LineOptions); + + Preconditions.checkArgument( + xValues.length == yValues.length, + 'Arrays must have same length'); + + D3Utils.checkArrayIsNumberOrNull(xValues); + D3Utils.checkArrayIsNumberOrNull(yValues); + + if (xStrings != undefined) { + Preconditions.checkArgumentArrayOf(xStrings, 'string'); + Preconditions.checkArgumentArrayLength(xStrings, xValues.length); + } else { + xStrings = new Array(xValues.length).fill(''); + } + + if (yStrings != undefined) { + Preconditions.checkArgumentArrayOf(yStrings, 'string'); + Preconditions.checkArgumentArrayLength(yStrings, yValues.length); + } else { + yStrings = new Array(xValues.length).fill(''); + } + + /** + * The X values + * @type {Array} + */ + this.xValues = xValues; + + /** + * The Y values + * @type {Array} + */ + this.yValues = yValues; + + /** + * Custom X value strings to be shown when viewing the data value + * @type {Array} + */ + this.xStrings = xStrings; + + /** + * Custom Y value strings to be shown when viewing the data value + * @type {Array} + */ + this.yStrings = yStrings; + + /** + * The D3LineOptions associated with XY values + * @type {D3LineOptions} + */ + this.lineOptions = options; + + /** + * The array of XY pair + * @type {Array} + */ + this.data = []; + + for (let xy of d3.zip(xValues, yValues, xStrings, yStrings)) { + this.data.push(new D3XYPair(xy[0], xy[1], xy[2], xy[3])); + } + + /** + * The D3 symbol generator. + */ + this.d3Symbol = d3.symbol().type(options.d3Symbol).size(options.d3SymbolSize); + } + + /** + * Given two D3LineSeriesData, find the common X values and return + * an array of XY pairs that were in common. + * + * @param {D3LineSeriesData} seriesA First series + * @param {D3LineSeriesData} seriesB Second series + * @return {Array} The array of common XY pairs + */ + static intersectionX(seriesA, seriesB) { + Preconditions.checkArgumentInstanceOf(seriesA, D3LineSeriesData); + Preconditions.checkArgumentInstanceOf(seriesB, D3LineSeriesData); + + let data = seriesA.data.filter((dataA) => { + let index = seriesB.data.findIndex((dataB) => { + return dataA.x == dataB.x; + }); + + return index != -1; + }); + + return data; + } + + /** + * Given two D3LineSeriesData, find the common Y values and return + * an array of XY pairs that were in common. + * + * @param {D3LineSeriesData} seriesA First series + * @param {D3LineSeriesData} seriesB Second series + * @return {Array} The array of common XY pairs + */ + static intersectionY(seriesA, seriesB) { + Preconditions.checkArgumentInstanceOf(seriesA, D3LineSeriesData); + Preconditions.checkArgumentInstanceOf(seriesB, D3LineSeriesData); + + let data = seriesA.data.filter((dataA) => { + let index = seriesB.data.findIndex((dataB) => { + return dataA.y == dataB.y; + }); + + return index != -1; + }); + + return data; + } + + /** + * Check to see if all X values are null + */ + checkXValuesNull() { + return this.data.every((xyPair) => { + return xyPair.x == null; + }); + } + + /** + * Check to see if all Y values are null + */ + checkYValuesNull() { + return this.data.every((xyPair) => { + return xyPair.y == null; + }); + } + + /** + * Remove all values under a cut off Y value. + * + * @param {Number} yMinCuttOff The cut off value + */ + removeSmallValues(yMinCuttOff) { + Preconditions.checkArgumentNumber(yMinCuttOff); + + this.data = this.data.filter((xyPair) => { + return xyPair.y > yMinCuttOff; + }); + } + +} diff --git a/webapp/apps/js/d3/data/D3XYPair.js b/webapp/apps/js/d3/data/D3XYPair.js new file mode 100644 index 000000000..61280fde2 --- /dev/null +++ b/webapp/apps/js/d3/data/D3XYPair.js @@ -0,0 +1,43 @@ + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview Container class to hold a X and Y pair + * + * @class D3LineSeriesData + * @author Brandon Clayton + */ +export class D3XYPair { + + /** + * @param {Number} x The X value + * @param {Number} y The Y value + * @param {String} xString Optional string representation + * @param {String} yString Optional string representation + */ + constructor(x, y, xString = '', yString = '') { + Preconditions.checkArgument( + typeof x == 'number' || x === null, + `Value [${x}] must be a number or null`); + + Preconditions.checkArgument( + typeof y == 'number' || y === null, + `Value [${y}] must be a number or null`); + + Preconditions.checkArgumentString(xString); + Preconditions.checkArgumentString(yString); + + /** @type {Number} The X value */ + this.x = x; + + /** @type {Number} The Y value */ + this.y = y; + + /** @type {String} The X value string representation */ + this.xString = xString; + + /** @type {String} The Y value string representation */ + this.yString = yString; + } + +} diff --git a/webapp/apps/js/d3/legend/D3LineLegend.js b/webapp/apps/js/d3/legend/D3LineLegend.js new file mode 100644 index 000000000..b0282d1d7 --- /dev/null +++ b/webapp/apps/js/d3/legend/D3LineLegend.js @@ -0,0 +1,636 @@ + +import { D3LineData } from '../data/D3LineData.js'; +import { D3LineLegendOptions } from '../options/D3LineLegendOptions.js'; +import { D3LinePlot } from '../D3LinePlot.js'; +import { D3LineSeriesData } from '../data/D3LineSeriesData.js'; +import { D3LineSubView } from '../view/D3LineSubView.js'; +import { D3Utils } from '../D3Utils.js'; +import { D3XYPair } from '../data/D3XYPair.js'; + +import NshmpError from '../../error/NshmpError.js'; +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview Create a legend for a D3LinePlot. + * + * @class D3LineLegend + * @author Brandon Clayton + */ +export class D3LineLegend { + + /** + * New instance of D3LineLegend + * + * @param {D3LinePlot} linePlot + */ + constructor(linePlot) { + Preconditions.checkArgumentInstanceOf(linePlot, D3LinePlot); + this.linePlot = linePlot; + } + + /** + * Create a legend on a sub view. + * + * @param {D3LineData} lineData The line data to show in the legend + */ + create(lineData) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + + if (!lineData.subView.options.showLegend) return; + + this.remove(lineData.subView); + this.show(lineData.subView); + this._createLegendTable(lineData); + this._legendSelectionListener(lineData); + } + + /** + * Hide the legend for specific sub view. + * + * @param {D3LineSubView} subView The sub view + */ + hide(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + subView.svg.legendEl.classList.add('hidden'); + } + + /** + * Hide legend on all sub views. + */ + hideAll() { + this.hide(this.linePlot.view.upperSubView); + + if (this.linePlot.view.addLowerSubView) { + this.hide(this.linePlot.view.lowerSubView); + } + } + + /** + * Remove the legend from the sub view. + * + * @param {D3LineSubView} subView + */ + remove(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + this.hide(subView); + + d3.select(subView.svg.legendForeignObjectEl) + .attr('height', 0) + .attr('width', 0); + + d3.select(subView.svg.legendTableEl) + .selectAll('*') + .remove(); + } + + /** + * Highlight a legend entry given an id of the line, + * D3LineSeries.lineOptions.id, by increasing the line width, + * marker size, and marker edge size based on + * D3LineSeries.lineOptions.selectionMultiplier. + * + * @param {String} id The id of the line series + * @param {...D3LineData} lineDatas The line datas + */ + selectLegendEntry(id, ...lineDatas) { + Preconditions.checkArgumentString(id); + Preconditions.checkArgumentArrayInstanceOf(lineDatas, D3LineData); + + for (let lineData of lineDatas) { + this._resetLegendSelection(lineData); + + d3.select(lineData.subView.svg.legendEl) + .selectAll(`#${id}`) + .each(( + /** @type {D3LineSeriesData} */ series, + /** @type {Number} */ i, + /** @type {NodeList} */ els) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + Preconditions.checkStateInstanceOf(els, NodeList); + if (!series.lineOptions.showInLegend) return; + this._legendSelection(lineData, series, els[i]); + }); + } + } + + /** + * Show the legend on specific sub view. + * + * @param {D3LineSubView} subView The sub view + */ + show(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + subView.svg.legendEl.classList.remove('hidden'); + } + + /** + * Show legends on all sub views. + */ + showAll() { + this.show(this.linePlot.view.upperSubView); + + if (this.linePlot.view.addLowerSubView) { + this.show(this.linePlot.view.lowerSubView); + } + } + + syncSubViews() { + for (let lineData of [this.linePlot.upperLineData, this.linePlot.lowerLineData]) { + d3.select(lineData.subView.svg.legendEl) + .selectAll('.legend-entry') + .on('click', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + this.linePlot.selectLine( + series.lineOptions.id, + this.linePlot.upperLineData, + this.linePlot.lowerLineData); + }); + } + } + + + /** + * @private + * Add lines representing the data. + * + * @param {SVGElement} tableSvgEl The SVG table element + * @param {D3LineSeriesData} series The data + * @param {D3LineLegendOptions} legendOptions The legend options + */ + _addLegendLines(tableSvgEl, series, legendOptions) { + Preconditions.checkArgumentInstanceOfSVGElement(tableSvgEl); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOf(legendOptions, D3LineLegendOptions); + + d3.select(tableSvgEl) + .append('line') + .attr('class', 'legend-line') + .attr('x2', legendOptions.lineLength) + .attr('stroke-width', series.lineOptions.lineWidth) + .attr('stroke-dasharray', series.lineOptions.svgDashArray) + .attr('stroke', series.lineOptions.color) + .style('shape-rendering', 'geometricPrecision') + .attr('fill', 'none'); + } + + /** + * @private + * Add the symbols representing the data. + * + * @param {SVGElement} tableSvgEl The SVG table element + * @param {D3LineSeriesData} series The data + * @param {D3LineLegendOptions} legendOptions The legend options + */ + _addLegendSymbols(tableSvgEl, series, legendOptions) { + Preconditions.checkArgumentInstanceOfSVGElement(tableSvgEl); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOf(legendOptions, D3LineLegendOptions); + + let size = series.lineOptions.d3SymbolSize; + let symbol = d3.symbol().type(series.lineOptions.d3Symbol).size(size)(); + let rotate = series.lineOptions.d3SymbolRotate; + let transform = `translate(${legendOptions.lineLength / 2}, 0) rotate(${rotate})`; + + d3.select(tableSvgEl) + .append('path') + .attr('class', 'legend-symbol') + .attr('d', symbol) + .attr('transform', transform) + .attr('fill', series.lineOptions.markerColor) + .attr('stroke', series.lineOptions.markerEdgeColor) + .attr('stroke-width', series.lineOptions.markerEdgeWidth) + .style('shape-rendering', 'geometricPrecision') + } + + /** + * @private + * Add the legend text representing the data. + * + * @param {HTMLElement} tableRowEl The HTML table row element + * @param {D3LineSeriesData} series The data + * @param {D3LineLegendOptions} legendOptions The legend options + */ + _addLegendText(tableRowEl, series, legendOptions) { + Preconditions.checkArgumentInstanceOfHTMLElement(tableRowEl); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOf(legendOptions, D3LineLegendOptions); + + d3.select(tableRowEl) + .append('td') + .attr('class', 'legend-text') + .style('padding', '0 5px') + .style('font-size', `${legendOptions.fontSize}px`) + .attr('nowrap', true) + .text(series.lineOptions.label); + } + + /** + * @private + * Add each D3LineSeriesData to the legend. + * + * @param {HTMLElement} tableRowEl The HTML table row element + * @param {D3LineSeriesData} series The data + * @param {D3LineLegendOptions} legendOptions The legend options + */ + _addSeriesToLegend(tableRowEl, series, legendOptions) { + Preconditions.checkArgumentInstanceOfHTMLElement(tableRowEl); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOf(legendOptions, D3LineLegendOptions); + + d3.select(tableRowEl) + .attr('id', series.lineOptions.id) + .attr('class', 'legend-entry') + .datum(series); + + let tableSvgEl = this._addTableSVG(tableRowEl, series, legendOptions); + this._addLegendLines(tableSvgEl, series, legendOptions); + this._addLegendSymbols(tableSvgEl, series, legendOptions); + this._addLegendText(tableRowEl, series, legendOptions); + } + + /** + * @private + * Add the SVG element to the lengend table row. + * + * @param {HTMLElement} tableRowEl The table row element + * @param {D3LineSeriesData} series The data series + * @param {D3LineLegendOptions} legendOptions The legend options + * @returns {SVGElement} The SVG element + */ + _addTableSVG(tableRowEl, series, legendOptions) { + Preconditions.checkArgumentInstanceOfHTMLElement(tableRowEl); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOf(legendOptions, D3LineLegendOptions); + + let lineOptions = series.lineOptions; + let markerSize = 2 * lineOptions.markerSize; + let lineWidth = lineOptions.lineWidth; + + let rowWidth = legendOptions.lineLength; + let rowHeight = legendOptions.fontSize > markerSize && + legendOptions.fontSize > lineWidth ? legendOptions.fontSize : + markerSize > lineWidth ? markerSize : lineWidth; + + let tableSvgD3 = d3.select(tableRowEl) + .append('td') + .attr('class', 'legend-svg') + .style('padding', '0 5px') + .style('height', `${rowHeight}px`) + .style('width', `${rowWidth}px`) + .style('line-height', 0) + .append('svg') + .attr('version', 1.1) + .attr('xmlns', 'http://www.w3.org/2000/svg') + .attr('height', rowHeight) + .attr('width', rowWidth) + .append('g') + .attr('transform', `translate(0, ${rowHeight / 2})`); + + let svgEl = tableSvgD3.node(); + Preconditions.checkArgumentInstanceOfSVGElement(svgEl); + + return svgEl; + } + + /** + * @private + * Add all legend entries as table row. + * + * @param {D3LineData} lineData The line data + * @param {Array>} tableRowData The data + * @param {D3LineLegendOptions} legendOptions The legend options + */ + _addTableRows(lineData, tableRowData, legendOptions) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + for (let row of tableRowData) { + Preconditions.checkArgumentArrayInstanceOf(row, D3LineSeriesData); + } + Preconditions.checkArgumentInstanceOf(legendOptions, D3LineLegendOptions); + + let showExtraEntries = tableRowData.length > legendOptions.maxRows; + tableRowData = showExtraEntries ? + tableRowData.slice(0, legendOptions.maxRows) : tableRowData; + + let tableEl = lineData.subView.svg.legendTableEl; + d3.select(tableEl) + .selectAll('tr') + .data(tableRowData) + .enter() + .append('tr') + .style('cursor', 'pointer') + .each(( + /** @type {Array} */ data, + /** @type {Number}*/ i, + /** @type {Array}*/ els) => { + Preconditions.checkStateArrayInstanceOf(data, D3LineSeriesData); + + for (let series of data) { + this._addSeriesToLegend(els[i], series, legendOptions); + } + }); + + if (showExtraEntries) { + let nSeries = lineData.toLegendSeries().length; + let extraEntries = nSeries - + ( legendOptions.maxRows * legendOptions.numberOfColumns ); + + d3.select(tableEl) + .append('tr') + .append('td') + .attr('colspan', legendOptions.numberOfColumns * 2) + .style('text-align', 'center') + .text(`... and ${extraEntries} more ...`); + } + + } + + /** + * @private + * Add the table styling from D3LineLegendOptions. + * + * @param {D3LineData} lineData The line data + */ + _addTableStyling(lineData) { + Preconditions.checkStateInstanceOf(lineData, D3LineData); + let legendOptions = lineData.subView.options.legendOptions; + + let padding = `${legendOptions.paddingTop}px ${legendOptions.paddingRight}px ` + + `${legendOptions.paddingBottom}px ${legendOptions.paddingLeft}px`; + + let borderStyle = `${legendOptions.borderLineWidth}px ` + + `${legendOptions.borderStyle} ${legendOptions.borderColor}`; + + d3.select(lineData.subView.svg.legendTableEl) + .style('font-size', `${legendOptions.fontSize}px`) + .style('border-collapse', 'separate') + .style('border', borderStyle) + .style('border-radius', `${legendOptions.borderRadius}px`) + .style('box-shadow', '0 1px 1px rgba(0, 0, 0, 0.05)') + .style('padding', padding) + .style('background', legendOptions.backgroundColor) + .style('cursor', 'move') + .style('border-spacing', '0') + .style('line-height', 'inherit'); + } + + /** + * @private + * Create the legend table for all legend entries. + * + * @param {D3LineData} lineData The line data + */ + _createLegendTable(lineData) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + + d3.select(lineData.subView.svg.legendForeignObjectEl) + .attr('height', '100%') + .attr('width', '100%'); + + let legendOptions = lineData.subView.options.legendOptions; + let legendLineSeries = lineData.toLegendSeries(); + let tableRowData = this._getTableRowData(legendLineSeries, legendOptions); + + this._addTableStyling(lineData); + this._addTableRows(lineData, tableRowData, legendOptions); + + let tableEl = lineData.subView.svg.legendTableEl; + let legendHeight = parseFloat(d3.select(tableEl).style('height')); + let legendWidth = parseFloat(d3.select(tableEl).style('width')); + + d3.select(lineData.subView.svg.legendEl) + .call(this._legendDrag(lineData.subView, legendHeight, legendWidth)); + + let loc = this._legendLocation(lineData.subView, legendHeight, legendWidth); + + d3.select(lineData.subView.svg.legendForeignObjectEl) + .style('height', `${ legendHeight }px`) + .style('width', `${ legendWidth }px`) + .style('overflow', 'visible') + .attr('x', loc.x) + .attr('y', loc.y); + } + + /** + * @private + * Split up the D3LineSeriesData array when using multiple + * columns in a legend; + * + * @param {Array} legendLineSeries The line data + * @param {D3LineLegendOptions} legendOptions The legend options + * @returns {Array>} + */ + _getTableRowData(legendLineSeries, legendOptions) { + Preconditions.checkArgumentArrayInstanceOf(legendLineSeries, D3LineSeriesData); + Preconditions.checkArgumentInstanceOf(legendOptions, D3LineLegendOptions); + + let data = []; + let nSeries = legendLineSeries.length; + let nRows = Math.ceil( nSeries / legendOptions.numberOfColumns ); + + for (let row = 0; row < nRows; row++) { + let splitStart = row * legendOptions.numberOfColumns; + let splitEnd = ( row + 1 ) * legendOptions.numberOfColumns; + let series = legendLineSeries.slice(splitStart, splitEnd); + data.push(series); + } + + return data; + } + + /** + * @private + * Create a d3 drag function. + * + * @param {D3LineSubView} subView The sub view + * @param {Number} legendHeight The legend height + * @param {Number} legendWidth The legend width + */ + _legendDrag(subView, legendHeight, legendWidth) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentNumber(legendHeight); + Preconditions.checkArgumentNumber(legendWidth); + + let drag = d3.drag() + .filter(() => { + return d3.event.target == subView.svg.legendTableEl; + }) + .on('drag', () => { + this._onLegendDrag(subView, legendHeight, legendWidth); + }); + + return drag; + } + + /** + * @private + * Calculate the X and Y location of where the legend should be placed. + * + * @param {D3LineSubView} subView The sub view + * @param {Number} legendHeight The legend height + * @param {Number} legendWidth The legend width + * @returns {D3XYPair} + */ + _legendLocation(subView, legendHeight, legendWidth) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + Preconditions.checkArgumentNumber(legendHeight); + Preconditions.checkArgumentNumber(legendWidth); + + let x = 0; + let y = 0; + let plotHeight = subView.plotHeight; + let plotWidth = subView.plotWidth; + let legendOptions = subView.options.legendOptions; + + let xRight = plotWidth - legendWidth - legendOptions.marginRight + let xLeft = legendOptions.marginLeft; + let yTop = legendOptions.marginTop; + let yBottom = plotHeight - legendHeight - legendOptions.marginBottom; + + switch(legendOptions.location) { + case 'top-right': + x = xRight + y = yTop; + break; + case 'top-left': + x = xLeft; + y = yTop; + break; + case 'bottom-right': + x = xRight; + y = yBottom; + break; + case 'bottom-left': + x = xLeft; + y = yBottom; + break; + default: + NshmpError.throwError(`Cannot set [${legendOptions.location}] legend location`); + } + + return new D3XYPair(x, y); + } + + /** + * @private + * Add a on click event to the legend entries + * + * @param {D3LineData} lineData The line data + */ + _legendSelectionListener(lineData) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + + d3.select(lineData.subView.svg.legendEl) + .selectAll('.legend-entry') + .on('click', (/** @type {D3LineSeriesData */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + this.linePlot.selectLine(series.lineOptions.id, lineData); + }); + } + + /** + * @private + * Handle the legend entry highlighting. + * + * @param {D3LineData} lineData The line data + * @param {D3LineSeriesData} series The data series + * @param {SVGElement} tableRowEl The table row element + */ + _legendSelection(lineData, series, tableRowEl) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentInstanceOfHTMLElement(tableRowEl); + + let isActive = !tableRowEl.classList.contains('active'); + let lineEls = tableRowEl.querySelectorAll('.legend-line'); + let symbolEls = tableRowEl.querySelectorAll('.legend-symbol'); + + d3.select(lineData.subView.svg.legendEl) + .selectAll('.legend-entry') + .classed('active', false); + + tableRowEl.classList.toggle('active', isActive); + let legendOptions = lineData.subView.options.legendOptions; + let fontWeight = isActive ? 'bold' : 'normal'; + let fontSize = legendOptions.fontSize; + + d3.select(tableRowEl) + .select('.legend-text') + .style('font-weight', fontWeight) + .style('font-size', `${fontSize}px`); + + D3Utils.linePlotSelection(series, lineEls, symbolEls, isActive); + } + + /** + * @private + * Handle the legend drag event. + * + * @param {D3LineSubView} subView The sub view + */ + _onLegendDrag(subView, legendHeight, legendWidth) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + d3.event.sourceEvent.stopPropagation(); + + let x = parseFloat(subView.svg.legendForeignObjectEl.getAttribute('x')); + x += d3.event.dx; + + let y = parseFloat(subView.svg.legendForeignObjectEl.getAttribute('y')); + y += d3.event.dy; + + let plotHeight = subView.plotHeight; + let plotWidth = subView.plotWidth; + let legendOptions = subView.options.legendOptions; + + let checkLeft = legendOptions.marginLeft; + let checkRight = plotWidth - legendWidth - legendOptions.marginRight; + let checkTop = legendOptions.marginTop; + let checkBottom = plotHeight - legendHeight - legendOptions.marginBottom; + + x = x < checkLeft ? checkLeft : + x > checkRight ? checkRight : x; + + y = y < checkTop ? checkTop : + y > checkBottom ? checkBottom : y; + + d3.select(subView.svg.legendForeignObjectEl) + .attr('x', x) + .attr('y', y); + } + + /** + * @private + * Reset any legend entry selections. + * + * @param {D3LineData} lineData The line data + */ + _resetLegendSelection(lineData) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + + d3.select(lineData.subView.svg.legendEl) + .selectAll('.legend-line') + .attr('stroke-width', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return series.lineOptions.lineWidth; + }); + + d3.select(lineData.subView.svg.legendEl) + .selectAll('.legend-symbol') + .attr('d', (/** @type {D3LineSeriesData}*/ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return series.d3Symbol.size(series.lineOptions.d3SymbolSize)(); + }) + .attr('stroke-width', (/** @type {D3LineSeriesData} */ series) => { + Preconditions.checkStateInstanceOf(series, D3LineSeriesData); + return series.lineOptions.markerEdgeWidth; + }); + + let legendOptions = lineData.subView.options.legendOptions; + + d3.select(lineData.subView.svg.legendEl) + .selectAll('.legend-text') + .style('font-size', `${legendOptions.fontSize}px`) + .style('font-weight', 'normal'); + } + +} diff --git a/webapp/apps/js/d3/options/D3BaseSubViewOptions.js b/webapp/apps/js/d3/options/D3BaseSubViewOptions.js new file mode 100644 index 000000000..063e81b75 --- /dev/null +++ b/webapp/apps/js/d3/options/D3BaseSubViewOptions.js @@ -0,0 +1,459 @@ + +import { D3SaveFigureOptions } from './D3SaveFigureOptions.js'; +import { D3TooltipOptions } from './D3TooltipOptions.js'; + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview Create options for D3BaseSubView. + * + * Use D3BaseSubViewOptions.lowerBuilder or + * D3BaseSubViewOptions.upperBuilder to customize options + * for lower and upper sub view or use + * D3BaseSubViewOptions.upperWithDefaults() or + * D3BaseSubViewOptions.lowerWithDefaults() for default options. + * + * Note: The only difference between upperWithDefaults() and + * lowerWithDefault() is the plot height. The lower view defaults with + * 224 px for plot height while the upper is 504 px. + * + * @class D3BaseSubViewOptions + * @author Brandon Clayton + */ +export class D3BaseSubViewOptions { + + /** + * @private + * Must use D3BaseSubViewOptions.lowerBuilder() or + * D3BaseSubViewOptions.upperBuilder() + * + * @param {D3BaseSubViewOptionsBuilder} builder The builder + */ + constructor(builder) { + Preconditions.checkArgumentInstanceOf(builder, D3BaseSubViewOptionsBuilder); + + /** + * The filename for downloading + * Default: 'file' + * @type {String} + */ + this.filename = builder._filename; + + /** + * The label for the sub view + * Default: 'upper' || 'lower' + * @type {String} + */ + this.label = builder._label; + + /** + * Margin bottom for the SVG plot in px. + * Default: 15 + * @type {Number} + */ + this.marginBottom = builder._marginBottom; + + /** + * Margin left for the SVG plot in px. + * Default: 20 + * @type {Number} + */ + this.marginLeft = builder._marginLeft; + + /** + * Margin right for the SVG plot in px. + * Default: 10 + * @type {Number} + */ + this.marginRight = builder._marginRight; + + /** + * Margin top for the SVG plot in px. + * Default: 10 + * @type {Number} + */ + this.marginTop = builder._marginTop; + + /** + * Padding bottom for the SVG plot in px. + * Default: 35 + * @type {Number} + */ + this.paddingBottom = builder._paddingBottom; + + /** + * Padding left for the SVG plot in px. + * Default: 40 + * @type {Number} + */ + this.paddingLeft = builder._paddingLeft; + + /** + * Padding right for the SVG plot in px. + * Default: 20 + * @type {Number} + */ + this.paddingRight = builder._paddingRight; + + /** + * Padding top for the SVG plot in px. + * Default: 10 + * @type {Number} + */ + this.paddingTop = builder._paddingTop; + + /** + * SVG plot height for SVG view box in px. + * Default: 504 (upper) || 224 (lower) + * @type {Number} + */ + this.plotHeight = builder._plotHeight; + + /** + * SVG plot width for SVG view box in px. + * Default: 896 + * @type {Number} + */ + this.plotWidth = builder._plotWidth; + + /** + * The sub view type: 'lower' || 'upper' + * @type {String} + */ + this.subViewType = builder._subViewType; + + /** + * The save figure options. + * Default: D3SaveFigureOptions.withDefaults() + * @type {D3SaveFigureOptions} + */ + this.saveFigureOptions = builder._saveFigureOptions; + + /** + * The tooltip options. + * Default: D3TooltipOptions.withDefaults() + * @type {D3TooltipOptions} + */ + this.tooltipOptions = builder._tooltipOptions; + + /* Make immutable */ + if (new.target == D3BaseSubViewOptions) Object.freeze(this); + } + + /** + * Return new D3BaseSubViewOptions.Builder for lower sub view + * + * @returns {D3BaseSubViewOptionsBuilder} The lower base sub view + * options builder + */ + static lowerBuilder() { + const LOWER_PLOT_HEIGHT = 224; + return new D3BaseSubViewOptionsBuilder() + ._type('lower') + .plotHeight(LOWER_PLOT_HEIGHT); + } + + /** + * Return new D3BaseSubViewOptions for lower sub view + * + * @returns {D3BaseSubViewOptions} The lower base sub view options + */ + static lowerWithDefaults() { + return D3BaseSubViewOptions.lowerBuilder().build(); + } + + /** + * Return new D3BaseSubViewOptions.Builder for upper sub view + * + * @returns {D3BaseSubViewOptionsBuilder} The upper base sub view + * options builder + */ + static upperBuilder() { + return new D3BaseSubViewOptionsBuilder()._type('upper'); + } + + /** + * Return new D3BaseSubViewOptions for upper sub view + * + * @returns {D3BaseSubViewOptions} The upper base sub view options + */ + static upperWithDefaults() { + return D3BaseSubViewOptions.upperBuilder().build(); + } + +} + +/** + * @fileoverview Builder for D3BaseSubViewOptions + * + * Use D3BaseSubViewOptions.lowerBuilder() or + * D3BaseSubViewOptions.upperBuilder() to get new instance of builder. + * + * @class D3SubViewOptionsBuilder + * @author Brandon Clayton + */ +export class D3BaseSubViewOptionsBuilder { + + /** @private */ + constructor() { + /** @type {String} */ + this._filename = 'file'; + + /** @type {String} */ + this._label = ''; + + /** @type {Number} */ + this._marginBottom = 15; + + /** @type {Number} */ + this._marginLeft = 20; + + /** @type {Number} */ + this._marginRight = 10; + + /** @type {Number} */ + this._marginTop = 10; + + /** @type {Number} */ + this._paddingBottom = 35; + + /** @type {Number} */ + this._paddingLeft = 40; + + /** @type {Number} */ + this._paddingRight = 20; + + /** @type {Number} */ + this._paddingTop = 10; + + /** @type {Number} */ + this._plotHeight = 504; + + /** @type {Number} */ + this._plotWidth = 896; + + /** @type {D3SaveFigureOptions} */ + this._saveFigureOptions = D3SaveFigureOptions.withDefaults(); + + /** @type {D3TooltipOptions} */ + this._tooltipOptions = D3TooltipOptions.withDefaults(); + + /** @type {String} */ + this._subViewType = 'upper'; + } + + /** + * Return new D3BaseSubViewOptions + */ + build() { + this._checkHeight(); + this._checkWidth(); + return new D3BaseSubViewOptions(this); + } + + /** + * Set the filename for downloading. + * Default: 'file' + * + * @param {String} name The filename + */ + filename(name) { + Preconditions.checkArgumentString(name); + this._filename = name; + return this; + } + + /** + * Set the label for the sub view. + * Default: '' + * + * @param {String} label The label + */ + label(label) { + Preconditions.checkArgumentString(label); + this._label = label; + return this; + } + + /** + * Set the bottom margin for the SVG plot in px. + * Default: 15 + * + * @param {Number} margin The bottom margin + */ + marginBottom(margin) { + Preconditions.checkArgumentInteger(margin); + this._marginBottom = margin; + return this; + } + + /** + * Set the left margin for the SVG plot in px. + * Default: 20 + * + * @param {Number} margin The left margin + */ + marginLeft(margin) { + Preconditions.checkArgumentInteger(margin); + this._marginLeft = margin; + return this; + } + + /** + * Set the right margin for the SVG plot in px. + * Default: 10 + * + * @param {Number} margin The right margin + */ + marginRight(margin) { + Preconditions.checkArgumentInteger(margin); + this._marginRight = margin; + return this; + } + + /** + * Set the top margin for the SVG plot in px. + * Default: 10 + * + * @param {Number} margin The top margin + */ + marginTop(margin) { + Preconditions.checkArgumentInteger(margin); + this._marginTop = margin; + return this; + } + + /** + * Set the bottom padding for the SVG plot in px. + * Default: 35 + * + * @param {Number} margin The bottom margin + */ + paddingBottom(padding) { + Preconditions.checkArgumentInteger(padding); + this._paddingBottom = padding; + return this; + } + + /** + * Set the left padding for the SVG plot in px. + * Default: 40 + * + * @param {Number} margin The left margin + */ + paddingLeft(padding) { + Preconditions.checkArgumentInteger(padding); + this._paddingLeft = padding; + return this; + } + + /** + * Set the right padding for the SVG plot in px. + * Default: 20 + * + * @param {Number} margin The right margin + */ + paddingRight(padding) { + Preconditions.checkArgumentInteger(padding); + this._paddingRight = padding; + return this; + } + + /** + * Set the top padding for the SVG plot in px. + * Default: 10 + * + * @param {Number} margin The top margin + */ + paddingTop(padding) { + Preconditions.checkArgumentInteger(padding); + this._paddingTop = padding; + return this; + } + + /** + * Set the SVG plot height in px. + * Default: 504 (upper) || 224 (lower) + * + * @param {number} height The plot height + */ + plotHeight(height) { + Preconditions.checkArgumentInteger(height); + this._plotHeight = height; + return this; + } + + /** + * Set the SVG plot width in px. + * Default: 896 + * + * @param {number} width The plot width + */ + plotWidth(width) { + Preconditions.checkArgumentInteger(width); + this._plotWidth = width; + return this; + } + + /** + * Set the save figure options. + * Default: D3SaveFigureOptions.withDefaults() + * + * @param {D3SaveFigureOptions} options The save figure options + */ + saveFigureOptions(options) { + Preconditions.checkArgumentInstanceOf(options, D3SaveFigureOptions); + this._saveFigureOptions = options; + return this; + } + + /** + * Set the tooltip options. + * Default: D3TooltipOptions.withDefaults() + * + * @param {D3TooltipOptions} options The tooltip options + */ + tooltipOptions(options) { + Preconditions.checkArgumentInstanceOf(options, D3TooltipOptions); + this._tooltipOptions = options; + return this; + } + + /** + * Check if plot height is good. + */ + _checkHeight() { + let heightCheck = this._plotHeight - + this._marginBottom - this._marginTop; + + Preconditions.checkState( + heightCheck > 0, + 'Height must be greater than (marginTop + marginBottom)'); + } + + /** + * Check if plot width is good + */ + _checkWidth() { + let widthCheck = this._plotWidth - + this._marginLeft - this._marginRight; + + Preconditions.checkState( + widthCheck > 0, + 'Width must be greater than (marginLeft + marginRight)'); + } + + /** + * @param {String} type + */ + _type(type) { + type = type.toLowerCase(); + Preconditions.checkArgument( + type == 'lower' || type == 'upper', + `Sub view type [${type}] not supported`); + + this._subViewType = type; + return this; + } + +} diff --git a/webapp/apps/js/d3/options/D3BaseViewOptions.js b/webapp/apps/js/d3/options/D3BaseViewOptions.js new file mode 100644 index 000000000..9a603bfc6 --- /dev/null +++ b/webapp/apps/js/d3/options/D3BaseViewOptions.js @@ -0,0 +1,173 @@ + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview Create options for D3BaseView. + * + * Use D3BaseViewOptions.builder() to customize the options or use + * D3BaseViewOptions.withDefault() for default options. + * + * @class D3BaseViewOptions + * @author Brandon Clayton + */ +export class D3BaseViewOptions { + + /** + * @private + * Must use D3BaseViewOptions.builder() + * + * @param {D3BaseViewOptionsBuilder} builder The builder + */ + constructor(builder) { + Preconditions.checkArgumentInstanceOf(builder, D3BaseViewOptionsBuilder); + + /** + * The title font size in the view's header in px. + * Default: 18 + * @type {Number} + */ + this.titleFontSize = builder._titleFontSize; + + /** + * The D3BaseView view size to start with, either: + * 'min' || 'minCenter' || 'max' + * + * Default value: 'max' + * @type {String} + */ + this.viewSizeDefault = builder._viewSizeDefault; + + /** + * The Bootstrap column size when viewSizeDefault is 'max' + * @type {String} + */ + this.viewSizeMax = builder._viewSizeMax; + + /** + * The Bootstrap column size when viewSizeDefault is 'min' + * @type {String} + */ + this.viewSizeMin = builder._viewSizeMin; + + /** + * The Bootstrap column size when viewSizeDefault is 'minCenter' + * @type {String} + */ + this.viewSizeMinCenter = builder._viewSizeMinCenter; + + /* Make immutable */ + if (new.target == D3BaseViewOptions) Object.freeze(this); + } + + /** + * Return a new D3BaseViewOptions instance with default options + */ + static withDefaults() { + return D3BaseViewOptions.builder().build(); + } + + /** + * Return a new D3BaseViewOptions.Builder + */ + static builder() { + return new D3BaseViewOptionsBuilder(); + } + +} + +/** + * @fileoverview Builder for D3BaseViewOptions + * + * Use D3BaseViewOptions.builder() for new instance of builder. + * + * @class D3BaseViewOptionsBuilder + * @author Brandon Clayton + */ +export class D3BaseViewOptionsBuilder { + + /** @private */ + constructor() { + this._titleFontSize = 18; + + this._viewSizeMin = 'col-sm-12 col-md-6'; + + this._viewSizeMinCenter = 'col-sm-offset-1 col-sm-10 ' + + 'col-xl-offset-2 col-xl-8 col-xxl-offset-3 col-xxl-6'; + + this._viewSizeMax = 'col-sm-12 col-xl-offset-1 col-xl-10 ' + + 'col-xxl-offset-2 col-xxl-8'; + + this._viewSizeDefault = 'max'; + } + + /** + * Return new D3BaseViewOptions instance + */ + build() { + return new D3BaseViewOptions(this); + } + + /** + * Set the title font size in px. + * Default: 18 + * + * @param {Number} fontSize The title font size + */ + titleFontSize(fontSize) { + Preconditions.checkArgumentInteger(fontSize); + this._titleFontSize = fontSize; + return this; + } + + /** + * Set the D3BaseView view size + * + * @param {String} size The view size, either: + * 'min' || 'minCenter' || 'max' + */ + viewSize(size) { + Preconditions.checkArgument( + size == 'min' || size == 'minCenter' || size == 'max', + `View size [${size}] not supported`); + + this._viewSizeDefault = size; + return this; + } + + /** + * Set the Bootstrap column size when viewSize is'min' + * + * @param {String} size The Bootstrap column size with + * viewSize is 'min' + */ + viewSizeMin(size) { + Preconditions.checkArgumentString(size); + this._viewSizeMin = size; + return this; + } + + /** + * Set the Bootstrap column size when viewSize is'minCenter' + * + * @param {String} size The Bootstrap column size with + * viewSize is 'minCenter' + */ + viewSizeMinCenter(size) { + Preconditions.checkArgumentString(size); + this._viewSizeMinCenter = size; + return this; + } + + /** + * Set the Bootstrap column size when viewSize is'max' + * + * @param {String} size The Bootstrap column size with + * viewSize is 'max' + */ + viewSizeMax(size) { + Preconditions.checkArgumentString(size); + this._viewSizeMax = size; + return this; + } + +} diff --git a/webapp/apps/js/d3/options/D3LineLegendOptions.js b/webapp/apps/js/d3/options/D3LineLegendOptions.js new file mode 100644 index 000000000..beb155aae --- /dev/null +++ b/webapp/apps/js/d3/options/D3LineLegendOptions.js @@ -0,0 +1,509 @@ + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview The options for D3LineLegend + * + * Use D3LineLegendOptions.builder() to customize legend options or + * D3LineLegendOptions.withDefaults() for default legend options. + * + * @class D3LineLegendOptions + * @author Brandon Clayton + */ +export class D3LineLegendOptions { + + /** + * + * @param {D3LineLegendOptionsBuilder} builder + */ + constructor(builder) { + Preconditions.checkArgumentInstanceOf(builder, D3LineLegendOptionsBuilder); + + /** + * The legend background color; 'none' for no color. + * Default: 'white' + * @type {String} + */ + this.backgroundColor = builder._backgroundColor; + + /** + * The legend border color. + * Default: 'gray' + * @type {String} + */ + this.borderColor = builder._borderColor; + + /** + * The legend border width in px. + * Default: 1 + * @type {Number} width The border width in px + */ + this.borderLineWidth = builder._borderLineWidth; + + /** + * The legend border radius in px. + * Default: 4 + * @type {Number} radius The border radius + */ + this.borderRadius = builder._borderRadius; + + /** + * The legend CSS border style. + * Default: 'solid' + * @type {String} + */ + this.borderStyle = builder._borderStyle; + + /** + * The legend font size + * Default: 12 + * @type {Number} + */ + this.fontSize = builder._fontSize; + + /** + * The line length of the line shown in the legend + * Default: 40 + * @type {Number} + */ + this.lineLength = builder._lineLength; + + /** + * The legend location: + * - 'bottom-left' + * - 'bottom-right' + * - 'top-left' + * - 'top-right' + * Default: 'top-right' + * @type {String} + */ + this.location = builder._location; + + /** + * The bottom margin of the legend + * Default: 10 + * @type {Number} + */ + this.marginBottom = builder._marginBottom; + + /** + * The left margin of the legend + * Default: 10 + * @type {Number} + */ + this.marginLeft = builder._marginLeft; + + /** + * The right margin of the legend + * Default: 10 + * @type {Number} + */ + this.marginRight = builder._marginRight; + + /** + * The top margin of the legend + * Default: 10 + * @type {Number} + */ + this.marginTop = builder._marginTop; + + /** + * The number of maximum rows a legend can have. If a legend + * has more rows then maxRows a '... and X more ...' is + * added to the legend. + * Default: 20 (upper sub view) || 4 (lower sub view) + * @type {Number} + */ + this.maxRows = builder._maxRows; + + /** + * The number of columns for the legend to have. + * Default: 1 + * @type {Number} + */ + this.numberOfColumns = builder._numberOfColumns; + + /** + * The bottom padding in the legend + * Default: 10 + * @type {Number} + */ + this.paddingBottom = builder._paddingBottom; + + /** + * The left padding in the legend. + * Default: 10 + * @type {Number} + */ + this.paddingLeft = builder._paddingLeft; + + /** + * The right padding in the legend. + * Default: 10 + * @type {Number} + */ + this.paddingRight = builder._paddingRight; + + /** + * The top padding in the legend. + * Default: 10 + * @type {Number} + */ + this.paddingTop = builder._paddingTop; + + /* Make immutable */ + Object.freeze(this); + } + + /** + * Return new D3LineLegendOptionsBuilder for lower sub view. + * Only difference between lowerBuilder and upperBuilder is + * maxRows is set to 4 for lowerBuilder and 20 for upperBuilder. + * + * @returns {D3LineLegendOptionsBuilder} New options builder + */ + static lowerBuilder() { + const LOWER_PLOT_MAX_ROWS = 4; + return new D3LineLegendOptionsBuilder().maxRows(LOWER_PLOT_MAX_ROWS); + } + + /** + * Return new D3LineLegendOptionsBuilder for upper sub view. + * Only difference between lowerBuilder and upperBuilder is + * maxRows is set to 6 for lowerBuilder and 22 for upperBuilder. + * + * @returns {D3LineLegendOptionsBuilder} New options builder + */ + static upperBuilder() { + return new D3LineLegendOptionsBuilder(); + } + + /** + * Return new D3LineLegendOptions with defaults for lower sub view. + * Only difference between lowerBuilder and upperBuilder is + * maxRows is set to 6 for lowerBuilder and 22 for upperBuilder. + * + * @returns {D3LineLegendOptions} New options with defaults + */ + static lowerWithDefaults() { + return D3LineLegendOptions.lowerBuilder().build(); + } + + /** + * Return new D3LineLegendOptions with defaults for upper sub view. + * Only difference between lowerBuilder and upperBuilder is + * maxRows is set to 6 for lowerBuilder and 22 for upperBuilder. + * + * @returns {D3LineLegendOptions} New options with defaults + */ + static upperWithDefaults() { + return D3LineLegendOptions.upperBuilder().build(); + } + +} + +/** + * @fileoverview Builder for D3LineLegendOptions + * + * Use D3LineLegendOptions.builder() to get new instance of builder. + * + * @class D3LineLegendOptionsBuilder + * @author Brandon Clayton + */ +export class D3LineLegendOptionsBuilder { + + /** @private */ + constructor() { + /** @type {String} */ + this._backgroundColor = 'white'; + + /** @type {String} */ + this._borderColor = 'gray'; + + /** @type {Number} */ + this._borderLineWidth = 1; + + /** @type {Number} */ + this._borderRadius = 4; + + /** @type {String} */ + this._borderStyle = 'solid'; + + /** @type {Number} */ + this._fontSize = 12; + + /** @type {Number} */ + this._lineLength = 40; + + /** @type {String} */ + this._location = 'top-right'; + + /** @type {Number} */ + this._marginBottom = 10; + + /** @type {Number} */ + this._marginLeft = 10; + + /** @type {Number} */ + this._marginRight = 10; + + /** @type {Number} */ + this._marginTop = 10; + + /** @type {Number} */ + this._maxRows = 20; + + /** @type {Number} */ + this._numberOfColumns = 1; + + /** @type {Number} */ + this._paddingBottom = 10; + + /** @type {Number} */ + this._paddingLeft = 10; + + /** @type {Number} */ + this._paddingRight = 10; + + /** @type {Number} */ + this._paddingTop = 10; + } + + /** + * Return new D3LineLegendOptions + * @returns {D3LineLegendOptions} New legend options + */ + build() { + return new D3LineLegendOptions(this); + } + + /** + * Set the legend background color; 'none' for no color. + * Default: 'white' + * + * @param {String} color The background color + */ + backgroundColor(color) { + Preconditions.checkArgumentString(color); + this._backgroundColor = color; + return this; + } + + /** + * Set the legend border color. + * Default: 'gray' + * + * @param {String} color The border color + */ + borderColor(color) { + Preconditions.checkArgumentString(color); + this._borderColor = color; + return this; + } + + /** + * Set the legend border width in px. + * Default: 1 + * + * @param {Number} width The border width in px + */ + borderLineWidth(width) { + Preconditions.checkArgumentInteger(width); + this._borderLineWidth = width; + return this; + } + + /** + * Set the legend border radius in px. + * Default: 4 + * + * @param {Number} radius The border radius + */ + borderRadius(radius) { + Preconditions.checkArgumentInteger(radius); + this._borderRadius = radius; + return this; + } + + /** + * Set the lgeend CSS border style. + * Default: 'solid' + * + * @param {String} style The border style + */ + borderStyle(style) { + Preconditions.checkArgumentString(style); + this._borderStyle = style; + return this; + } + + /** + * Set the tooltip font size. + * Default: 12 + * + * @param {Number} size The font size + */ + fontSize(size) { + Preconditions.checkArgumentInteger(size); + this._fontSize = size; + return this; + } + + /** + * Set the line length for the line shown in the legend. + * Default: 40 + * @param {Number} length + */ + lineLength(length) { + Preconditions.checkArgumentNumber(length); + this._lineLength = length; + return this; + } + + /** + * Set the legend location: + * - 'bottom-left' + * - 'bottom-right' + * - 'top-left' + * - 'top-right' + * Default: 'top-right' + * + * @param {String} loc Legend location + */ + location(loc) { + Preconditions.checkArgumentString(loc); + loc = loc.toLowerCase(); + Preconditions.checkArgument( + loc == 'bottom-left' || + loc == 'bottom-right' || + loc == 'top-left' || + loc == 'top-right', + `Legend location [${loc}] not supported`); + + this._location = loc; + return this; + } + + /** + * Set the bottom margin of the legend. + * Default: 10 + * + * @param {Number} margin The bottom margin in px + */ + marginBottom(margin) { + Preconditions.checkArgumentInteger(margin); + this._marginBottom = margin; + return this; + } + + /** + * Set the left margin of the legend. + * Default: 10 + * + * @param {Number} margin The left margin in px + */ + marginLeft(margin) { + Preconditions.checkArgumentInteger(margin); + this._marginLeft = margin; + return this; + } + + /** + * Set the right margin of the legend. + * Default: 10 + * + * @param {Number} margin The right margin in px + */ + marginRight(margin) { + Preconditions.checkArgumentInteger(margin); + this._marginRight = margin; + return this; + } + + /** + * Set the top margin of the legend. + * Default: 10 + * + * @param {Number} margin The top margin in px + */ + marginTop(margin) { + Preconditions.checkArgumentInteger(margin); + this._marginTop = margin; + return this; + } + + /** + * Set the number of maximum rows a legend can have. If a legend + * has more rows then maxRows a '... and X more ...' is + * added to the legend. + * Default: 10 + * + * @param {Number} rows The max rows + */ + maxRows(rows) { + Preconditions.checkArgumentInteger(rows); + this._maxRows = rows; + return this; + } + + /** + * Set the number of columns for the legend to have. + * Default: 1 + * + * @param {Number} col The number of columns in the legend + */ + numberOfColumns(col) { + Preconditions.checkArgumentInteger(col); + this._numberOfColumns = col; + return this; + } + + /** + * Set the bottom padding in the tooltip. + * Default: 10 + * + * @param {Number} pad The bottom padding in px + */ + paddingBottom(pad) { + Preconditions.checkArgumentInteger(pad); + this._paddingBottom = pad; + return this; + } + + /** + * Set the left padding in the tooltip. + * Default: 10 + * + * @param {Number} pad The left padding in px + */ + paddingLeft(pad) { + Preconditions.checkArgumentInteger(pad); + this._paddingLeft = pad; + return this; + } + + /** + * Set the right padding in the tooltip. + * Default: 10 + * + * @param {Number} pad The right padding in px + */ + paddingRight(pad) { + Preconditions.checkArgumentInteger(pad); + this._paddingRight = pad; + return this; + } + + /** + * Set the top padding in the tooltip. + * Default: 10 + * + * @param {Number} pad The top padding in px + */ + paddingTop(pad) { + Preconditions.checkArgumentInteger(pad); + this._paddingTop = pad; + return this; + } + +} diff --git a/webapp/apps/js/d3/options/D3LineOptions.js b/webapp/apps/js/d3/options/D3LineOptions.js new file mode 100644 index 000000000..6b9b46cce --- /dev/null +++ b/webapp/apps/js/d3/options/D3LineOptions.js @@ -0,0 +1,541 @@ + +import NshmpError from '../../error/NshmpError.js'; +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview Options for customizing a line in a line plot. + * + * Use D3LineOptions.builder() to get new instance of D3LineOptionsBuilder. + * See D3LineOptions.builder() + * See D3LineOptionsBuilder + * + * @class D3LineOptions + * @author Brandon Clayton + */ +export class D3LineOptions { + + /** + * @private + * Must use D3LineOptions.builder() + * + * @param {D3LineOptionsBuilder} builder The builder + */ + constructor(builder) { + Preconditions.checkArgumentInstanceOf(builder, D3LineOptionsBuilder); + + /** + * The line color. + * The default color is set based on the current color scheme + * in D3LineData.colorScheme + * @type {String} + */ + this.color = builder._color; + + /** + * The id of the line, should have no spaces. + * @type {String} + */ + this.id = builder._id; + + /** + * The label of the line to show in the tooltip and legend + * @type {String} + */ + this.label = builder._label; + + /** + * The line style: + * - '-' || 'solid': Solid line + * - '--' || 'dashed': Dashed line + * - ':' || 'dotted': Dotted line + * - '-:' || 'dash-dot': Dahsed-dotted + * - 'none': No line + * Default: 'solid' + * @type {String} + */ + this.lineStyle = builder._lineStyle; + + /** + * The line width. + * Default: 2.5 + * @type {Number} + */ + this.lineWidth = builder._lineWidth; + + /** + * The marker color. + * The default color is set based on the current color scheme + * in D3LineData.colorScheme + * @type {String} + */ + this.markerColor = builder._markerColor; + + /** + * The marker edge color. + * The default color is set based on the current color scheme + * in D3LineData.colorScheme + * @type {String} + */ + this.markerEdgeColor = builder._markerEdgeColor; + + /** + * The marker edge width. + * Default: 1.0 + * @type {Number} + */ + this.markerEdgeWidth = builder._markerEdgeWidth; + + /** + * The marker size. + * Default: 6 + * @type {Number} + */ + this.markerSize = builder._markerSize; + + /** + * The marker style: + * - 's' || 'square': Square markers + * - 'o' || 'circle': Circle markers + * - '+' || 'plus-sign': Plus sign markers + * - 'x' || 'cross': Cross sign markers + * - '^' || 'up-triangle': Up-pointing triangle + * - 'v' || 'down-triangle': Down-pointing triangle + * - '<' || 'left-triangle': Left-pointing triangle + * - '>' || 'right-triangle': Right-pointing triangle + * - 'd' || 'diamond': Diamond markers + * - '*' || 'star': Star markers + * Default: 'circle' + * @type {String} + */ + this.markerStyle = builder._markerStyle; + + /** + * Whether the data is selectable in the plot. + * Default: true + * @type {Boolean} + */ + this.selectable = builder._selectable; + + /** + * The plot selection multiplier to be applied to the + * line width, marker size, and marker edge size, when a line + * or marker is selected. + * Default: 2.0 + * @type{Number} + */ + this.selectionMultiplier = builder._selectionMultiplier; + + /** + * Whether to show the data in the legend. + * Default: true + * @type {Boolean} + */ + this.showInLegend = builder._showInLegend; + + /** + * The SVG dash array based on the lineStyle + * @type {String} + */ + this.svgDashArray = this._getDashArray(); + + /** + * The D3 symbol associated with the marker style. + * @type {Object} + */ + this.d3Symbol = this._getD3Symbol(); + + /** + * The D3 symbol sizes are are, square pixels. + * @type {Number} + */ + this.d3SymbolSize = Math.pow(this.markerSize, 2); + + /** + * The D3 symbol rotate. + * @type {Number} + */ + this.d3SymbolRotate = this._getD3SymbolRotate(); + + /* Make immutable */ + Object.freeze(this); + } + + /** + * Create a new D3LineOptions with default options. + * @returns {D3LineOptions} New D3LineOptions instance + */ + static withDefaults() { + return D3LineOptions.builder().build(); + } + + /** + * Create line options for reference lines. + */ + static withRefLineDefaults() { + return D3LineOptions.builder().color('black').build(); + } + + /** + * Returns a new D3LineOptionsBuilder + * @returns {D3LineOptionsBuilder} New builder + */ + static builder() { + return new D3LineOptionsBuilder(); + } + + /** + * @private + */ + _getDashArray() { + let dashArray; + + switch(this.lineStyle) { + case '-' || 'solid': + dashArray = ''; + break; + case '--' || 'dashed': + dashArray = '8, 8'; + break; + case ':' || 'dotted': + dashArray = '2, 5'; + break; + case '-.' || 'dash-dot': + dashArray = '8, 5, 2, 5'; + break; + case 'none': + dashArray = '0, 1'; + break; + default: + NshmpError.throwError(`Line style [${this.lineStyle}] not supported`); + } + + return dashArray; + } + + /** + * @private + */ + _getD3Symbol() { + let symbol; + + switch(this.markerStyle) { + case '+' || 'plus-sign': + case 'x' || 'cross': + symbol = d3.symbolCross; + break; + case 'd' || 'diamond': + symbol = d3.symbolDiamond; + break; + case '*' || 'star': + symbol = d3.symbolStar; + break; + case '^' || 'up-triangle': + case 'v' || 'down-triangle': + case '<' || 'left-triangle': + case '>' || 'right-triangle': + symbol = d3.symbolTriangle; + break; + case 'o' || 'circle': + symbol = d3.symbolCircle; + break; + case 's' || 'square': + symbol = d3.symbolSquare; + break; + case 'none': + symbol = null; + break; + default: + NshmpError.throwError(`Marker [${this.markerStyle}] not supported`); + } + + Preconditions.checkNotUndefined(symbol, 'D3 symbol not found'); + + return symbol; + } + + /** + * @private + */ + _getD3SymbolRotate() { + let rotate; + + switch(this.markerStyle) { + case 'x' || 'cross': + rotate = 45; + break; + case 'v' || 'down-triangle': + rotate = 180; + break; + case '<' || 'left-triangle': + rotate = -90; + break; + case '>' || 'right-triangle': + rotate = 90; + break; + default: + rotate = 0; + } + + return rotate; + } + +} + +/** + * @fileoverview Builder for D3LineOptions + * + * Use D3LineOptions.builder() for new instance of D3LineOptionsBuilder + * + * @class D3LineOptionsBuilder + * @author Brandon Clayton + */ +export class D3LineOptionsBuilder { + + /** @private */ + constructor() { + /** @type {String} */ + this._color = undefined; + + /** @type {String} */ + this._id = undefined; + + /** @type {String} */ + this._label = undefined; + + /** @type {String} */ + this._lineStyle = '-'; + + /** @type {Number} */ + this._lineWidth = 2.5; + + /** @type {String} */ + this._markerStyle = 'o'; + + /** @type {String} */ + this._markerColor = undefined; + + /** @type {String} */ + this._markerEdgeColor = undefined; + + /** @type {Number} */ + this._markerEdgeWidth = 1.0; + + /** @type {Number} */ + this._markerSize = 6.0; + + /** @type {Boolean} */ + this._selectable = true; + + /** @type {Number} */ + this._selectionMultiplier = 2; + + /** @type {Boolean} */ + this._showInLegend = true; + } + + /** + * Returns new D3LineOptions + * + * @returns {D3LineOptions} new D3LineOptions + */ + build() { + return new D3LineOptions(this); + } + + /** + * Copy D3LineOptions into the builder. + * + * @param {D3LineOptions} options The options to copy + */ + fromCopy(options) { + Preconditions.checkArgumentInstanceOf(options, D3LineOptions); + + this._color = options.color; + this._id = options.id; + this._label = options.label; + this._lineStyle = options.lineStyle; + this._lineWidth = options.lineWidth; + this._markerColor = options.markerColor; + this._markerEdgeColor = options.markerEdgeColor; + this._markerEdgeWidth = options.markerEdgeWidth; + this._markerStyle = options.markerStyle; + this._markerSize = options.markerSize; + this._selectable = options.selectable; + this._selectionMultiplier = options.selectionMultiplier; + this._showInLegend = options.showInLegend; + + return this; + } + + /** + * Set the line color. + * The default color is set based on the current color scheme + * in D3LineData.colorScheme. + * + * @param {String} color The line color + */ + color(color) { + Preconditions.checkArgumentString(color); + this._color = color; + return this; + } + + /** + * Set the id of the line. + * + * @param {String} id The id of the line + */ + id(id) { + Preconditions.checkArgumentString(id); + this._id = id; + return this; + } + + /** + * Set the label for the line. Shown in tooltip and legend. + * + * @param {String} label The label for the line + */ + label(label) { + Preconditions.checkArgumentString(label); + this._label = label; + return this; + } + + /** + * Set the line style: + * - '-' || 'solid': Solid line + * - '--' || 'dashed': Dashed line + * - ':' || 'dotted': Dotted line + * - '-:' || 'dash-dot': Dahsed-dotted + * - 'none': No line + * Default: 'solid' + * + * @param {String} style + */ + lineStyle(style) { + Preconditions.checkArgumentString(style); + this._lineStyle = style.toLowerCase(); + return this; + } + + /** + * Set the line width. + * Default: 2.5 + * + * @param {Number} width The line width + */ + lineWidth(width) { + Preconditions.checkArgumentNumber(width); + this._lineWidth = width; + return this; + } + + /** + * Set the marker color. + * The default color is set based on the current color scheme + * in D3LineData.colorScheme + * + * @param {String} color + */ + markerColor(color) { + Preconditions.checkArgumentString(color); + this._markerColor = color; + return this; + } + + /** + * Set the marker edge color. + * The default color is set based on the current color scheme + * in D3LineData.colorScheme + * + * @param {String} color The marker edge color + */ + markerEdgeColor(color) { + Preconditions.checkArgumentString(color); + this._markerEdgeColor = color; + return this; + } + + /** + * Set the marker edge width. + * Default: 1.0 + * + * @param {Number} width The marker edge width + */ + markerEdgeWidth(width) { + Preconditions.checkArgumentNumber(width); + this._markerEdgeWidth = width; + return this; + } + + /** + * The marker size. + * Default: 6 + * @type {Number} + */ + markerSize(size) { + Preconditions.checkArgumentNumber(size); + this._markerSize = size; + return this; + } + + /** + * Set the marker style: + * - 's' || 'square': Square markers + * - 'o' || 'circle': Circle markers + * - '+' || 'plus-sign': Plus sign markers + * - 'x' || 'cross': Cross sign markers + * - '^' || 'up-triangle': Up-pointing triangle + * - 'v' || 'down-triangle': Down-pointing triangle + * - '<' || 'left-triangle': Left-pointing triangle + * - '>' || 'right-triangle': Right-pointing triangle + * - 'd' || 'diamond': Diamond markers + * - '*' || 'star': Star markers + * Default: 'circle' + * + * @param {String} marker + */ + markerStyle(marker) { + Preconditions.checkArgumentString(marker); + this._markerStyle = marker.toLowerCase(); + return this; + } + + /** + * Set whether the data can be selected in the plot. + * + * @param {Boolean} selectable Whether data is selectable + */ + selectable(selectable) { + Preconditions.checkArgumentBoolean(selectable); + this._selectable = selectable; + return this; + } + + /** + * Set the plot selection multiplier to be applied to the + * line width, marker size, and marker edge size, when a line + * or marker is selected. + * Default: 2.0 + * + * @param {Number} mult The multiplier + */ + selectionMultiplier(mult) { + Preconditions.checkArgumentNumber(mult); + this._selectionMultiplier = mult; + return this; + } + + /** + * Whether to show the data in the legend. + * Default: true + * @type {Boolean} + */ + showInLegend(bool) { + Preconditions.checkArgumentBoolean(bool); + this._showInLegend = bool; + return this; + } + +} diff --git a/webapp/apps/js/d3/options/D3LineSubViewOptions.js b/webapp/apps/js/d3/options/D3LineSubViewOptions.js new file mode 100644 index 000000000..9857c942c --- /dev/null +++ b/webapp/apps/js/d3/options/D3LineSubViewOptions.js @@ -0,0 +1,811 @@ + +import { D3BaseSubViewOptions } from './D3BaseSubViewOptions.js'; +import { D3BaseSubViewOptionsBuilder } from './D3BaseSubViewOptions.js'; +import { D3LineLegendOptions } from './D3LineLegendOptions.js'; + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview Create options for D3LineSubView. + * + * Use D3LineSubViewOptions.lowerBuilder() or + * D3LineSubViewOptions.upperBuilder() to customize options + * for lower and upper sub view or use + * D3LineSubViewOptions.upperWithDefaults() or + * D3LineSubViewOptions.lowerWithDefaults() for default options. + * + * Note: The only difference between upperWithDefaults() and + * lowerWithDefault() is the plot height. The lower view defaults with + * 224 px for plot height while the upper is 504 px. + * + * @class D3LineSubViewOptions + * @extends D3BaseSubViewOptions + * @author Brandon Clayton + */ +export class D3LineSubViewOptions extends D3BaseSubViewOptions { + + /** + * @private + * Must use D3LineSubViewOptions.builder() + * + * @param {D3LineSubViewOptionsBuilder} builder The builder + */ + constructor(builder) { + super(builder); + + /** + * The font weight for the X and Y axis labels. + * Default: 500 + * @type {Number} + */ + this.axisLabelFontWeight = builder._axisLabelFontWeight; + + /** + * The default X limit when the D3LineView is shown with no data. + * Default: [ 0.01, 10 ] + * @type {Array} + */ + this.defaultXLimit = builder._defaultXLimit; + + /** + * The default X limit when the D3LineView is shown with no data. + * Default: [ 0.01, 1 ] + * @type {Array} + */ + this.defaultYLimit = builder._defaultYLimit; + + /** + * Snap lines that are being dragged to the nearest specified value. + * Default: 0.01 + * @type {Number} + */ + this.dragLineSnapTo = builder._dragLineSnapTo; + + /** + * Color of axes grid lines. + * Default: '#E0E0E0' + * @type {String} + */ + this.gridLineColor = builder._gridLineColor; + + /** + * Width of axes grid lines. + * Default: 0.75 + * @type {Number} + */ + this.gridLineWidth = builder._gridLineWidth; + + /** + * Axes label font size in px. + * Default: 16 + * @type {Number} + */ + this.labelFontSize = builder._labelFontSize; + + /** + * The legend options. + * Default: D3LineLegendOptions.withDefaults() + * @type {D3LineLegendOptions} + */ + this.legendOptions = builder._legendOptions; + + /** + * A label to represent what the data represents. + * Default: 'Line' + * @type {String} + */ + this.lineLabel = builder._lineLabel; + + /** + * Color of a reference line. + * Default: 'gray' + * @type {String} + */ + this.referenceLineColor = builder._referenceLineColor; + + /** + * Line width of the reference line. + * Default: 1.5 + * @type {Number} + */ + this.referenceLineWidth = builder._referenceLineWidth; + + /** + * Whether to show the legend regardless of using the legend toggle. + * Default: true + * @type {Boolean} + */ + this.showLegend = builder._showLegend; + + /** + * The font size of the exponents on the tick mark values in px. + * Only when in log space. + * Default: 8 + * @type {Number} + */ + this.tickExponentFontSize = builder._tickExponentFontSize; + + /** + * The tick mark values font size in px. + * Default: 12 + * @type {Number} + */ + this.tickFontSize = builder._tickFontSize; + + /** + * The duration for any plot animations in milliseconds. + * e.g. switching between log and linear scales. + * Default: 500 + * @type {Number} + */ + this.translationDuration = builder._translationDuration; + + /** + * The X axis location: 'top' || 'bottom' + * Default: 'bottom' + * @type {String} + */ + this.xAxisLocation = builder._xAxisLocation; + + /** + * Extend the domain to start and end on nice round numbers. + * Default: true + * @type {Boolean} + */ + this.xAxisNice = builder._xAxisNice; + + /** + * The X axis scale: 'log' || 'linear' + * Default: 'linear' + * @type {String} + */ + this.xAxisScale = builder._xAxisScale; + + /** + * The number of digits after decimal place when + * xValueToExponent is set to true. + * Default: 4 + * @type {Number} digits The number of digits after decimal point + */ + this.xExponentFractionDigits = builder._xExponentFractionDigits; + + /** + * The X axis label; can be an HTML string. + * Default: 'X' + * @type {String} + */ + this.xLabel = builder._xLabel; + + /** + * Padding around the X label in px. + * Default: 8 + * @type {Number} + */ + this.xLabelPadding = builder._xLabelPadding; + + /** + * The number of tick marks for the X axis. + * The specified count is only a hint; the scale may return more or + * fewer values depending on the domain. + * Default: 8 + * @type {Number} + */ + this.xTickMarks = builder._xTickMarks; + + /** + * Whether to format the X value in exponential form when X value + * is shown on tooltip, in data view, and when saving data. + * Default: false + * @type{Boolean} + */ + this.xValueToExponent = builder._xValueToExponent; + + /** + * The Y axis location: 'left' || 'right' + * Default: 'left' + * @type {String} + */ + this.yAxisLocation = builder._yAxisLocation; + + /** + * Extend the domain to start and end on nice round numbers. + * Default: true + * @type {Boolean} + */ + this.yAxisNice = builder._yAxisNice; + + /** + * Whether to reverse the Y axis direction. + * Default: false + * @type {Boolean} + */ + this.yAxisReverse = builder._yAxisReverse; + + /** + * The Y axis scale: 'log' || 'linear' + * Default: 'linear' + * @type {String} + */ + this.yAxisScale = builder._yAxisScale; + + /** + * The number of digits after decimal place when + * yValueToExponent is set to true. + * Default: 4 + * @type {Number} digits The number of digits after decimal point + */ + this.yExponentFractionDigits = builder._xExponentFractionDigits; + + /** + * The Y axis label; can be an HTML string. + * Default: 'Y' + * @type {String} + */ + this.yLabel = builder._yLabel; + + /** + * Padding around the Y label in px. + * Default: 10 + * @type {Number} + */ + this.yLabelPadding = builder._yLabelPadding; + + /** + * The number of tick marks for the Y axis. + * The specified count is only a hint; the scale may return more or + * fewer values depending on the domain. + * Default: 6 + * @type {Number} + */ + this.yTickMarks = builder._yTickMarks; + + /** + * Whether to format the Y value in exponential form when Y value + * is shown on tooltip, in data view, and when saving data. + * Default: false + * @type{Boolean} + */ + this.yValueToExponent = builder._yValueToExponent; + + /* Make immutable */ + if (new.target == D3LineSubViewOptions) Object.freeze(this); + } + + /** + * Return new D3LineSubViewOptionsBuilder for lower sub view + */ + static lowerBuilder() { + const LOWER_PLOT_HEIGHT = 224; + return new D3LineSubViewOptionsBuilder() + ._type('lower') + .plotHeight(LOWER_PLOT_HEIGHT) + .legendOptions(D3LineLegendOptions.lowerWithDefaults()); + } + + /** + * Return new D3LineSubViewOptions for lower sub view + */ + static lowerWithDefaults() { + return D3LineSubViewOptions.lowerBuilder().build(); + } + + /** + * Return new D3LineSubViewOptionsBuilder for upper sub view + */ + static upperBuilder() { + return new D3LineSubViewOptionsBuilder() + ._type('upper'); + } + + /** + * Return new D3LineSubViewOptions for upper sub view + */ + static upperWithDefaults() { + return D3LineSubViewOptions.upperBuilder().build(); + } + +} + +/** + * @fileoverview Builder for D3LineSubViewOptions. + * + * Use D3LineSubViewOptions.lowerBuilder() or + * D3LineSubViewOptions.upperBuilder() for new instance of builder. + * + * @class D3LineSubViewOptionsBuilder + * @extends D3BaseSubViewOptionsBuilder + * @author Brandon Clayton + */ +export class D3LineSubViewOptionsBuilder + extends D3BaseSubViewOptionsBuilder { + + /** @private */ + constructor() { + super(); + + /** @type {Number} */ + this._axisLabelFontWeight = 500; + + /** @type {Array} */ + this._defaultXLimit = [ 0.01, 10 ]; + + /** @type {Array} */ + this._defaultYLimit = [ 0.01, 1 ]; + + /** @type {Number} */ + this._dragLineSnapTo = 0.01; + + /** @type {String} */ + this._gridLineColor = '#E0E0E0'; + + /** @type {Number} */ + this._gridLineWidth = 0.75; + + /** @type {Number} */ + this._labelFontSize = 16; + + /** @type {D3LineLegendOptions} */ + this._legendOptions = D3LineLegendOptions.upperWithDefaults(); + + /** @type {String} */ + this._lineLabel = 'Line'; + + /** @type {String} */ + this._referenceLineColor = 'gray'; + + /** @type {Number} */ + this._referenceLineWidth = 1.5; + + /** @type {Boolean} */ + this._showLegend = true; + + /** @type {Number} */ + this._tickExponentFontSize = 8; + + /** @type {Number} */ + this._tickFontSize = 12 + + /** @type {Number} */ + this._translationDuration = 500; + + /** @type {String} */ + this._xAxisLocation = 'bottom'; + + /** @type {Boolean} */ + this._xAxisNice = true; + + /** @type {String} */ + this._xAxisScale = 'linear'; + + /** @type {Number} */ + this._xExponentFractionDigits = 4; + + /** @type {String} */ + this._xLabel = 'X'; + + /** @type {Number} */ + this._xLabelPadding = 8; + + /** @type {Number} */ + this._xTickMarks = 8; + + /** @type {Boolean} */ + this._xValueToExponent = false; + + /** @type {String} */ + this._yAxisLocation = 'left'; + + /** @type {Boolean} */ + this._yAxisNice = true; + + /** @type {Boolean} */ + this._yAxisReverse = false; + + /** @type {String} */ + this._yAxisScale = 'linear'; + + /** @type {Number} */ + this._yExponentFractionDigits = 4; + + /** @type {String} */ + this._yLabel = 'Y'; + + /** @type {Number} */ + this._yLabelPadding = 10; + + /** @type {Number} */ + this._yTickMarks = 6; + + /** @type {Boolean} */ + this._yValueToExponent = false; + } + + /** + * Return new D3LineSubViewOptions + * @returns {D3LineSubViewOptions} Sub view options + */ + build() { + this._checkHeight(); + this._checkWidth(); + return new D3LineSubViewOptions(this); + } + + /** + * Set the font weight for the X and Y axis labels. + * Default: 500 + * @param {Number} weight The font weight + */ + axisLabelFontWeight(weight) { + Preconditions.checkArgumentInteger(weight); + this._axisLabelFontWeight = weight; + return this; + } + + /** + * Set the default X limit when the D3LineView is shown with no data. + * Default: [ 0.01, 10 ] + * @param {Array} xLimit The [ min, max] for the X axis + */ + defaultXLimit(xLimit) { + Preconditions.checkArgumentArrayLength(xLimit, 2); + Preconditions.checkArgumentArrayOf(xLimit, 'number'); + this._defaultXLimit = xLimit; + return this; + } + + /** + * Set the default Y limit when the D3LineView is shown with no data. + * Default: [ 0.01, 1 ] + * @param {Array} yLimit The [ min, max ] for the Y axis + */ + defaultYLimit(yLimit) { + Preconditions.checkArgumentArrayLength(yLimit, 2); + Preconditions.checkArgumentArrayOf(yLimit, 'number'); + this._defaultYLimit = yLimit; + return this; + } + + /** + * Snap a line to the nearest value when dragging. + * Default: 0.01 + * + * @param {Number} snapTo Snap to value + */ + dragLineSnapTo(snapTo) { + Preconditions.checkArgumentNumber(snapTo); + this._dragLineSnapTo = snapTo; + return this; + } + + /** + * Set the grid line color in HEX, rgb, or string name. + * Default: 'E0E0E0' + * @param {String} color The grid line color + */ + gridLineColor(color) { + Preconditions.checkArgumentString(color); + this._gridLineColor = color; + return this; + } + + /** + * Set the grid line width. + * Default: 0.75 + * @param {Number} width The grid line width + */ + gridLineWidth(width) { + Preconditions.checkArgumentNumber(width); + this._gridLineWidth = width; + return this; + } + + /** + * Set the legend options. + * Default: D3LineLegendOptions.withDefaults() + * + * @param {D3LineLegendOptions} options The legend options + */ + legendOptions(options) { + Preconditions.checkArgumentInstanceOf(options, D3LineLegendOptions); + this._legendOptions = options; + return this; + } + + /** + * A label representing what the line data is. + * Default: '' + * + * @param {String} label The line label + */ + lineLabel(label) { + Preconditions.checkArgumentString(label); + this._lineLabel = label; + return this; + } + + /** + * Set the reference line color in HEX, RGB, or string name. + * Default: '#9E9E9E' + * @param {String} color The color + */ + referenceLineColor(color) { + Preconditions.checkArgumentString(color); + this._referenceLineColor = color; + return this; + } + + /** + * Set the reference line width. + * Default: 1.5 + * @param {Number} width The width + */ + referenceLineWidth(width) { + Preconditions.checkArgumentNumber(width); + this._referenceLineWidth = width; + return this; + } + + /** + * Whether to show the legend regardless of using the legend toggle. + * Default: true + * + * @param {Boolean} show the legend + */ + showLegend(show) { + Preconditions.checkArgumentBoolean(show); + this._showLegend = show; + return this; + } + + /** + * Set the font size of the exponents on the axes tick marks. + * Only in log scale. + * Default: 6 + * @param {Number} size The font size + */ + tickExponentFontSize(size) { + Preconditions.checkArgumentInteger(size); + this._tickExponentFontSize = size; + return this; + } + + /** + * Set the axes tick mark font size. + * Default: 12 + * @param {Number} size + */ + tickFontSize(size) { + Preconditions.checkArgumentInteger(size); + this._tickFontSize = size; + return this; + } + + /** + * Set the transition duration in milliseconds. Used when switching + * between log and linear scale. + * Default: 500 + * @param {Number} time The duration + */ + translationDuration(time) { + Preconditions.checkArgumentInteger(time); + this._translationDuration = time; + return this; + } + + /** + * Set the X axis location: 'top' || 'bottom' + * Default: 'bottom' + * @param {String} loc The location + */ + xAxisLocation(loc) { + loc = loc.toLowerCase(); + Preconditions.checkArgument( + loc == 'bottom' || loc == 'top', + `X axis location [${loc}] not supported`); + + this._xAxisLocation = loc; + return this; + } + + /** + * Whether to extend the X domain to nice round numbers. + * Default: true + * @param {Boolean} bool Whether to have a nice domain + */ + xAxisNice(bool) { + Preconditions.checkArgumentBoolean(bool); + this._xAxisNice = bool; + return this; + } + + /** + * Set the X axis scale: 'log' || 'linear' + * Default: 'linear' + * @param {String} scale The X axis scale + */ + xAxisScale(scale) { + scale = scale.toLowerCase(); + Preconditions.checkArgument( + scale == 'log' || scale == 'linear', + `X axis scale [${scale}] not supported`); + + this._xAxisScale = scale; + return this; + } + + /** + * Set the number of digits after decimal place when + * xValueToExponent is set to true. + * Default: 4 + * + * @param {Number} digits The number of digits after decimal point + */ + xExponentFractionDigits(digits) { + Preconditions.checkArgumentInteger(digits); + this._xExponentFractionDigits = digits; + return this; + } + + /** + * Set the X axis label; can be an HTML string. + * Default: '' + * @param {String} label The X axis label + */ + xLabel(label) { + Preconditions.checkArgumentString(label); + this._xLabel = label; + return this; + } + + /** + * Set the X label padding in px. + * Default: 8 + * @param {Number} pad The padding + */ + xLabelPadding(pad) { + Preconditions.checkArgumentInteger(pad); + this._xLabelPadding = pad; + return this; + } + + /** + * Set the number of X axis tick marks. + * The specified count is only a hint; the scale may return more or + * fewer values depending on the domain. + * Default: 8 + * @param {Number} count Number of tick marks + */ + xTickMarks(count) { + Preconditions.checkArgumentInteger(count); + this._xTickMarks = count; + return this; + } + + /** + * Whether to format the X value in exponential form when X value + * is shown on tooltip, in data view, and when saving data. + * Default: false + * + * @param {Boolean} toExponenet Whether to format in exponential form + */ + xValueToExponent(toExponenet) { + Preconditions.checkArgumentBoolean(toExponenet); + this._xValueToExponent = toExponenet; + return this; + } + + /** + * Set the Y axis location: 'left' || 'right' + * Default: 'left' + * @param {String} loc The location + */ + yAxisLocation(loc) { + loc = loc.toLowerCase(); + Preconditions.checkArgument( + loc == 'left' || loc == 'right', + `Y axis location [${loc}] not supported`); + + this._yAxisLocation = loc; + return this; + } + + /** + * Whether to extend the Y domain to nice round numbers. + * Default: true + * @param {Boolean} bool Whether to have a nice domain + */ + yAxisNice(bool) { + Preconditions.checkArgumentBoolean(bool); + this._yAxisNice = bool; + return this; + } + + /** + * Whether to reverse the Y axis direction. + * Default: false + * + * @param {Boolean} bool To reverse Y axis + */ + yAxisReverse(bool) { + Preconditions.checkArgumentBoolean(bool); + this._yAxisReverse = bool; + return this; + } + + /** + * Set the Y axis scale: 'log' || 'linear' + * Default: 'linear' + * @param {String} scale The Y axis scale + */ + yAxisScale(scale) { + scale = scale.toLowerCase(); + Preconditions.checkArgument( + scale == 'log' || scale == 'linear', + `Y axis scale [${scale}] not supported`); + + this._yAxisScale = scale; + return this; + } + + /** + * Set the number of digits after decimal place when + * yValueToExponent is set to true. + * Default: 4 + * + * @param {Number} digits The number of digits after decimal point + */ + yExponentFractionDigits(digits) { + Preconditions.checkArgumentInteger(digits); + this._yExponentFractionDigits = digits; + return this; + } + + /** + * Set the Y axis label; can be an HTML string. + * Default: '' + * @param {String} label The Y axis label + */ + yLabel(label) { + Preconditions.checkArgumentString(label); + this._yLabel = label; + return this; + } + + /** + * Set the Y label padding in px. + * Default: 10 + * @param {Number} pad The padding + */ + yLabelPadding(pad) { + Preconditions.checkArgumentInteger(pad); + this._yLabelPadding = pad; + return this; + } + + /** + * Set the number of Y axis tick marks. + * The specified count is only a hint; the scale may return more or + * fewer values depending on the domain. + * Default: 6 + * @param {Number} count Number of tick marks + */ + yTickMarks(count) { + Preconditions.checkArgumentInteger(count); + this._yTickMarks = count; + return this; + } + + /** + * Whether to format the Y value in exponential form when Y value + * is shown on tooltip, in data view, and when saving data. + * Default: false + * + * @param {Boolean} toExponenet Whether to format in exponential form + */ + yValueToExponent(toExponenet) { + Preconditions.checkArgumentBoolean(toExponenet); + this._yValueToExponent = toExponenet; + return this; + } + +} diff --git a/webapp/apps/js/d3/options/D3LineViewOptions.js b/webapp/apps/js/d3/options/D3LineViewOptions.js new file mode 100644 index 000000000..6ba7ed709 --- /dev/null +++ b/webapp/apps/js/d3/options/D3LineViewOptions.js @@ -0,0 +1,237 @@ + +import { D3BaseViewOptions } from './D3BaseViewOptions.js'; +import { D3BaseViewOptionsBuilder } from './D3BaseViewOptions.js'; + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview Create options for D3LineView + * + * Use Builder to customize the options or use + * D3LineViewOptions.withDefaults() + * + * @class D3LineViewOptions + * @extends D3BaseViewOptions + * @author Brandon Clayton + */ +export class D3LineViewOptions extends D3BaseViewOptions { + + /** + * @private + * Use D3LineViewOptions.builder() + * + * @param {D3LineViewOptionsBuilder} builder The builder + */ + constructor(builder) { + super(builder); + + /** + * Whether to disable the X axis buttons on the view's footer. + * Default: false + * @type {Boolean} + */ + this.disableXAxisBtns = builder._disableXAxisBtns; + + /** + * Whether to disable the Y axis buttons on the view's footer. + */ + this.disableYAxisBtns = builder._disableYAxisBtns; + + /** + * Whether to sync the plot selections between the the upper and + * lower sub views. + * Default: false + * @type {Boolean} + */ + this.syncSubViewsSelections = builder._syncSubViewsSelections; + + /** + * Whether to sync the upper and + * lower sub views Y axis scale, 'log' or 'linear', when toggling + * the X axis buttons in the view's footer. + * Default: false + * @type {Boolean} + */ + this.syncXAxisScale = builder._syncXAxisScale; + + /** + * Whether to sync the upper and + * lower sub views Y axis scale, 'log' or 'linear', when toggling + * the Y axis buttons in the view's footer. + * Default: false + * @type {Boolean} + */ + this.syncYAxisScale = builder._syncYAxisScale; + + /** + * The X axis scale: 'log' || 'linear' + * NOTE: Overriden by D3LineSubViewOptions.xAxisScale if + * syncXAxisScale is false. + * Default: 'linear' + * @type {String} + */ + this.xAxisScale = builder._xAxisScale; + + /** + * The Y axis scale: 'log' || 'linear' + * NOTE: Overriden by D3LineSubViewOptions.yAxisScale if + * syncYAxisScale is false. + * Default: 'linear' + * @type {String} + */ + this.yAxisScale = builder._yAxisScale; + + /* Make immutable */ + if (new.target == D3LineViewOptions) Object.freeze(this); + } + + /** + * @override + * Return a new D3LineViewOptions instance with default options + */ + static withDefaults() { + return D3LineViewOptions.builder().build(); + } + + /** + * @override + * Return a new D3LineViewOptionsBuilder + */ + static builder() { + return new D3LineViewOptionsBuilder(); + } + +} + +/** + * @fileoverview Builder for D3LineViewOptions. + * + * Use D3LineViewOptions.builder() for new instance of builder. + * + * @class D3LineViewOptionsBuilder + * @extends D3BaseViewOptionsBuilder + * @author Brandon Clayton + */ +export class D3LineViewOptionsBuilder extends D3BaseViewOptionsBuilder { + + /** @private */ + constructor() { + super(); + + /** @type {Boolean} */ + this._disableXAxisBtns = false; + + /** @type {Boolean} */ + this._disableYAxisBtns= false; + + /** @type {Boolean} */ + this._syncSubViewsSelections = false; + + /** @type {Boolean} */ + this._syncXAxisScale = false; + + /** @type {Boolean} */ + this._syncYAxisScale = false; + + /** @type {String} */ + this._xAxisScale = 'linear'; + + /** @type {String} */ + this._yAxisScale = 'linear'; + } + + /** + * Return new D3LineViewOptions instance + */ + build() { + return new D3LineViewOptions(this); + } + + /** + * Whether to disable the X axis buttons on the view's footer. + * Default: false + * + * @param {Boolean} bool Whether to disable X axis buttons + */ + disableXAxisBtns(bool) { + Preconditions.checkArgumentBoolean(bool); + this._disableXAxisBtns = bool; + return this; + } + + /** + * Whether to disable the Y axis buttons on the view's footer. + * Default: false + * + * @param {Boolean} bool Whether to disable Y axis buttons + */ + disableYAxisBtns(bool) { + Preconditions.checkArgumentBoolean(bool); + this._disableYAxisBtns = bool; + return this; + } + + /** + * Whether to sync selection between the two sub views. + * Note: The data IDs for the upper and lower sub view must be the + * same to sync. + * + * Default: false + * + * @param {Boolean} bool Whether to sync sub views selections + */ + syncSubViewsSelections(bool) { + Preconditions.checkArgumentBoolean(bool); + this._syncSubViewsSelections = bool; + return this; + } + + /** + * Choose to sync the X axis scale between the two sub views starting + * with a specified scale. + * + * @param {Boolean} bool Whether to sync the X axis scale + * @param {String} scale What X axis scale to start with + */ + syncXAxisScale(bool, scale) { + Preconditions.checkArgumentBoolean(bool); + this._syncXAxisScale = bool; + + if (bool) { + Preconditions.checkArgumentString(scale); + scale = scale.toLowerCase(); + Preconditions.checkArgument( + scale == 'log' || scale == 'linear', + `X axis scale [${scale}] not supported`); + + this._xAxisScale = scale; + } + + return this; + } + + /** + * Choose to sync the Y axis scale between the two sub views starting + * with a specified scale. + * + * @param {Boolean} bool Whether to sync the Y axis scale + * @param {String} scale What Y axis scale to start with + */ + syncYAxisScale(bool, scale) { + Preconditions.checkArgumentBoolean(bool); + this._syncYAxisScale = bool; + + if (bool) { + Preconditions.checkArgumentString(scale); + scale = scale.toLowerCase(); + Preconditions.checkArgument( + scale == 'log' || scale == 'linear', + `Y axis scale [${scale}] not supported`); + + this._yAxisScale = scale; + } + + return this; + } + +} diff --git a/webapp/apps/js/d3/options/D3SaveFigureOptions.js b/webapp/apps/js/d3/options/D3SaveFigureOptions.js new file mode 100644 index 000000000..28c91e792 --- /dev/null +++ b/webapp/apps/js/d3/options/D3SaveFigureOptions.js @@ -0,0 +1,458 @@ + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview The options for D3SaveFigure + * + * Use D3SaveFigureOptions.builder() to get new instance of D3SaveFigureOptionsBuilder + * See D3SaveFigureOptions.builder() + * See D3SaveFigureOptionsBuilder + * + * @class D3SaveFigureOptions + * @author Brandon Clayton + */ +export class D3SaveFigureOptions { + + /** + * + * @param {D3SaveFigureOptionsBuilder} builder + */ + constructor(builder) { + Preconditions.checkArgumentInstanceOf(builder, D3SaveFigureOptionsBuilder); + + /** + * Whether to add a footer containing the URL and date to the plot page. + * Default: true + * @type {Boolean} + */ + this.addFooter = builder._addFooter; + + /** + * Whether to add a table of the metadata. + * Note: Metadata must be set using D3BaseView.setMetadata() + * Default: true + * @type {Boolean} + */ + this.addMetadata = builder._addMetadata; + + /** + * Whether to add the plot title. + * Default: true + * @type {Boolean} + */ + this.addTitle = builder._addTitle; + + /** + * The DPI to save the figure at. + * Default: 300 + * @type {Number} + */ + this.dpi = builder._dpi; + + /** + * The font size of the footer. + * Default: 10 + * @type {Number} + */ + this.footerFontSize = builder._footerFontSize; + + /** + * The footer line break distance in pixels. + * Deafult: 14 + * @type {Number} + */ + this.footerLineBreak = builder._footerLineBreak; + + /** + * The padding around the footer. + * Default: 10 + * @type {Number} + */ + this.footerPadding = builder._footerPadding; + + /** + * The left margin for the figure on the page. Can be either + * 'center' or number in inches. + * Default: 'center' + * @type {String | Number} + */ + this.marginLeft = builder._marginLeft; + + /** + * The top margin for the figure on the page in inches. + * Default: 0.5 + * @type {Number} + */ + this.marginTop = builder._marginTop; + + /** + * The number of columns allowed in the metadata table. + * Default: 3 + * @type {Number} + */ + this.metadataColumns = builder._metadataColumns; + + /** + * The metadata table font size in pixels. + * Default: 10 + * @type {Number} + */ + this.metadataFontSize = builder._metadataFontSize; + + /** + * The top margin in inches from the bottom of the figure + * to the metadata table. + * Default: 0.5 + * @type {Number} + */ + this.metadataMarginTop = builder._metadataMarginTop; + + /** + * The maximum number of values in a column in the metadata table. + * Once the values is greater than this a '... and X more ...' is added. + * Default: 5 + * @type {Number} + */ + this.metadataMaxColumnValues = builder._metadataMaxColumnValues; + + /** + * The page height in inches. + * Default: 8.5 + * @type {Number} + */ + this.pageHeight = builder._pageHeight; + + /** + * The page width in inches. + * Default: 11.0 + * @type {Number} + */ + this.pageWidth = builder._pageWidth; + + /** + * The title font size. + * Default: 14 + * @type {Number} + */ + this.titleFontSize = builder._titleFontSize; + + /** + * The title location, either: 'center' || 'left' + * Default: 'center' + * @type {String} + */ + this.titleLocation = builder._titleLocation; + + /* Make immutable */ + Object.freeze(this); + } + + /** + * Returns a new D3SaveFigureOptionsBuilder + */ + static builder() { + return new D3SaveFigureOptionsBuilder(); + } + + /** + * Returns new D3SaveFigureOptions with default values + */ + static withDefaults() { + return D3SaveFigureOptions.builder().build(); + } + +} + +/** + * @fileoverview The D3SaveFigureOptions builder + * + * Use D3SaveFigureOptions.builder() for new instance of builder + * + * @class D3SaveFigureOptionsBuilder + * @author Brandon Clayton + */ +export class D3SaveFigureOptionsBuilder { + + constructor() { + /** @type {Boolean} */ + this._addFooter = true; + + /** @type {Boolean} */ + this._addMetadata = true; + + /** @type {Boolean} */ + this._addTitle = true; + + /** @type {Number} */ + this._dpi = 300; + + /** @type {Number} */ + this._footerFontSize = 10; + + /** @type {Number} */ + this._footerLineBreak = 14; + + /** @type {Number} */ + this._footerPadding = 10; + + /** @type {String} */ + this._marginLeft = 'center'; + + /** @type {Number} */ + this._marginTop = 0.5; + + /** @type {Number} */ + this._metadataColumns = 3; + + /** @type {Number} */ + this._metadataFontSize = 10; + + /** @type {Number} */ + this._metadataMarginTop = 0.5; + + /** @type {Number} */ + this._metadataMaxColumnValues = 5; + + /** @type {Number} */ + this._pageHeight = 8.5; + + /** @type {Number} */ + this._pageWidth = 11; + + /** @type {Number} */ + this._titleFontSize = 14; + + /** @type {String} */ + this._titleLocation = 'center'; + } + + /** + * Return new instance of D3SaveFigureOptions + */ + build() { + return new D3SaveFigureOptions(this); + } + + /** + * Set whether to add a footer containing the URL and date to the plot page. + * Default: true + * + * @param {Boolean} addFooter Whether to add footer + */ + addFooter(addFooter) { + Preconditions.checkArgumentBoolean(addFooter); + this._addFooter = addFooter; + return this; + } + + /** + * Set whether to add a table of the metadata. + * Note: Metadata must be set using D3BaseView.setMetadata() + * Default: true + * + * @param {Boolean} addMetadata Whether to add metadata table + */ + addMetadata(addMetadata) { + Preconditions.checkArgumentBoolean(addMetadata); + this._addMetadata = addMetadata; + return this; + } + + /** + * Set whether to add the plot title. + * Default: true + * + * @param {Boolean} addTitle Whether to add title + */ + addTitle(addTitle) { + Preconditions.checkArgumentBoolean(addTitle); + this._addTitle = addTitle; + return this; + } + + /** + * Set the DPI to save the figure at. + * Default: 300 + * + * @param {Number} dpi The plot DPI to save at + */ + dpi(dpi) { + Preconditions.checkArgumentInteger(dpi); + this._dpi = dpi; + return this; + } + + /** + * Set the font size of the footer. + * Default: 10 + * + * @param {Number} fontSize The footer font size + */ + footerFontSize(fontSize) { + Preconditions.checkArgumentInteger(fontSize); + this._footerFontSize = fontSize; + return this; + } + + /** + * Set the footer line break distance in pixels. + * Deafult: 14 + * + * @param {Number} lineBreak The footer line break + */ + footerLineBreak(lineBreak) { + Preconditions.checkArgumentNumber(lineBreak); + this._footerLineBreak = lineBreak; + return this; + } + + /** + * Set the padding around the footer. + * Default: 10 + * + * @param {Number} padding The padding around the footer + */ + footerPadding(padding) { + Preconditions.checkArgumentInteger(padding); + this._footerPadding = padding; + return this; + } + + /** + * Set the left margin for the figure on the page. Can be either + * 'center' or number in inches. + * Default: 'center' + * + * @param {String | Number} marginLeft The left margin of the figure + */ + marginLeft(marginLeft) { + Preconditions.checkArgument( + typeof marginLeft == 'number' || typeof marginLeft == 'string', + `Wrong type [${typeof marginLeft}]`); + + if (typeof marginLeft == 'string') { + marginLeft.toLowerCase(); + Preconditions.checkArgument( + marginLeft == 'center', + `margin type [${marginLeft}] not supported`); + } + + return this; + } + + /** + * Set the top margin for the figure on the page in inches. + * Default: 0.5 + * + * @param {Number} marginTop The top margin of the plot + */ + marginTop(marginTop) { + Preconditions.checkArgumentInteger(marginTop); + this._marginTop = marginTop; + return this; + } + + /** + * Set the number of columns allowed in the metadata table. + * Default: 3 + * + * @param {Number} columns The number of metadata table columns + */ + metadataColumns(columns) { + Preconditions.checkArgumentInteger(columns); + this._metadataColumns = columns; + return this; + } + + /** + * Set the metadata table font size in pixels. + * Default: 10 + * + * @param {Number} fontSize The metadata table font size + */ + metadataFontSize(fontSize) { + Preconditions.checkArgumentInteger(fontSize); + this._metadataFontSize = fontSize; + return this; + } + + /** + * Set the top margin in inches from the bottom of the figure + * to the metadata table. + * Default: 0.5 + * + * @param {Number} marginTop The metadata top margin + */ + metadataMarginTop(marginTop) { + Preconditions.checkArgumentNumber(marginTop); + this._metadataMarginTop = marginTop; + return this; + } + + /** + * Set the maximum number of values in a column in the metadata table. + * Once the values is greater than this a '... and X more ...' is added. + * Default: 5 + * + * @param {Number} max The max column values + */ + metadataMaxColumnValues(max) { + Preconditions.checkArgumentInteger(max); + this._metadataMaxColumnValues = max; + return this; + } + + + /** + * Set the page height in inches. + * Default: 8.5 + * + * @param {Number} height The page height in inches + */ + pageHeight(height) { + Preconditions.checkArgumentNumber(height); + this._pageHeight = height; + return this; + } + + /** + * Set the page width in inches. + * Default: 11.0 + * + * @param {Number} width The page width in inches + */ + pageWidth(width) { + Preconditions.checkArgumentNumber(width); + this._pageWidth = width; + return this; + } + + /** + * Set the title font size. + * Default: 14 + * + * @param {Number} fontSize The title font size + */ + titleFontSize(fontSize) { + Preconditions.checkArgumentInteger(fontSize); + this._titleFontSize = fontSize; + return this; + } + + /** + * Set the title location, either: 'center' || 'left' + * Default: 'center' + * + * @param {String} loc The title location + */ + titleLocation(loc) { + Preconditions.checkArgumentString(loc); + loc = loc.toLowerCase(); + Preconditions.checkArgument( + loc == 'center' || loc == 'left', + `Title location [${loc}] not supported`); + + return this; + } + +} diff --git a/webapp/apps/js/d3/options/D3TextOptions.js b/webapp/apps/js/d3/options/D3TextOptions.js new file mode 100644 index 000000000..928dbbce3 --- /dev/null +++ b/webapp/apps/js/d3/options/D3TextOptions.js @@ -0,0 +1,282 @@ + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview Text options for plot when adding text to a plot. + * + * Use D3TextOptions.builder() to customize options. + * + * @class D3TextOptions + * @author Brandon Clayton + */ +export class D3TextOptions { + + /** + * @private + * + * @param {D3TextOptionsBuilder} builder The builder + */ + constructor(builder) { + Preconditions.checkArgumentInstanceOf(builder, D3TextOptionsBuilder); + + /** + * The alignment baseline. See CSS property alignment-baseline + * Default: 'baseline' + * @type {String} + */ + this.alignmentBaseline = builder._alignmentBaseline; + + /** + * The text color. + * Default: 'black' + * @type {String} + */ + this.color = builder._color; + + /** + * The horizontal shift in the text from the X position. + * Default: 0 + * @type {Number} + */ + this.dx = builder._dx; + + /** + * The vertical shift in the text from the Y position. + * Default: 0 + * @type {Number} + */ + this.dy = builder._dy; + + /** + * The text font size in pixels. + * Default: 14 + * @type {Number} + */ + this.fontSize = builder._fontSize; + + /** + * The font weight. + * Default: 400 + * @type {Number} + */ + this.fontWeight = builder._fontWeight; + + /** + * The text rotation. + * Default: 0 + * @type {Number} + */ + this.rotate = builder._rotate; + + /** + * The stroke color. + * Default: 'black' + * @type {String} + */ + this.stroke = builder._stroke; + + /** + * The stroke width. + * Default: 0 + * @type {Number} + */ + this.strokeWidth = builder._strokeWidth; + + /** + * The text anchor. See CSS property text-anchor. + * Default: 'start' + * @type {String} + */ + this.textAnchor = builder._textAnchor; + + /* Make immutable */ + Object.freeze(this); + } + + /** + * Return new builder. + */ + static builder() { + return new D3TextOptionsBuilder(); + } + + /** + * New instance of D3TextOptions with default options. + */ + static withDefaults() { + return D3TextOptions.builder().build(); + } + +} + +/** + * @fileoverview Builder for D3TextOptions + * + * @class D3TextOptionsBuilder + * @author Brandon Clayton + */ +export class D3TextOptionsBuilder { + + /** + * @private + */ + constructor() { + /** @type {String} */ + this._alignmentBaseline = 'baseline'; + + /** @type {String} */ + this._color = 'black'; + + /** @type {Number} */ + this._dx = 0; + + /** @type {Number} */ + this._dy = 0; + + /** @type {Number} */ + this._fontSize = 14; + + /** @type {Number} */ + this._fontWeight = 400; + + /** @type {Number} */ + this._rotate = 0; + + /** @type {String} */ + this._stroke = 'black'; + + /** @type {Number} */ + this._strokeWidth = 0; + + /** @type {String} */ + this._textAnchor = 'start'; + } + + /** + * Return new instance of D3TextOptions + */ + build() { + return new D3TextOptions(this); + } + + /** + * Set the alignment baseline. + * Default: 'baseline' + * + * @param {String} alignment The baseline + */ + alignmentBaseline(alignment) { + Preconditions.checkArgumentString(alignment); + this._alignmentBaseline = alignment; + return this; + } + + /** + * Set the text color. + * Default: 'black' + * + * @param {String} color The text color. + */ + color(color) { + Preconditions.checkArgumentString(color); + this._color = color; + return this; + } + + /** + * Set the shift in X. + * Default: 0 + * + * @param {Number} dx The horizontal shift + */ + dx(dx) { + Preconditions.checkArgumentNumber(dx); + this._dx = dx; + return this; + } + + /** + * Set the shift in Y. + * Default: 0 + * + * @param {Number} dy The vertical shift + */ + dy(dy) { + Preconditions.checkArgumentNumber(dy); + this._dy = dy; + return this; + } + + /** + * Set the text font size. + * Default: 14 + * + * @param {Number} fontSize The font size + */ + fontSize(fontSize) { + Preconditions.checkArgumentNumber(fontSize); + this._fontSize = fontSize; + return this; + } + + /** + * Set the font weight. + * Default: 400 + * + * @param {Number} fontWeight The weight + */ + fontWeight(fontWeight) { + Preconditions.checkArgumentInteger(fontWeight); + this._fontWeight = fontWeight; + return this; + } + +/** + * Set the text rotation. + * Default: 0 + * + * @param {Number} rotate The rotation + */ + rotate(rotate) { + Preconditions.checkArgumentNumber(rotate); + this._rotate = rotate; + return this; + } + + /** + * Set the stroke color. + * Default: 'black; + * + * @param {String} stroke The stroke + */ + stroke(stroke) { + Preconditions.checkArgumentString(stroke); + this._stroke = stroke; + return this; + } + + /** + * Set the stroke width. + * Default: 0 + * + * @param {Number} strokeWidth The width + */ + strokeWidth(strokeWidth) { + Preconditions.checkArgumentNumber(strokeWidth); + this._strokeWidth = strokeWidth; + return this; + } + + /** + * Set the text anchor. + * Default: 'start' + * + * @param {String} textAnchor The text anchor + */ + textAnchor(textAnchor) { + Preconditions.checkArgumentString(textAnchor); + this._textAnchor = textAnchor; + return this; + } + +} diff --git a/webapp/apps/js/d3/options/D3TooltipOptions.js b/webapp/apps/js/d3/options/D3TooltipOptions.js new file mode 100644 index 000000000..8b5a61ed5 --- /dev/null +++ b/webapp/apps/js/d3/options/D3TooltipOptions.js @@ -0,0 +1,329 @@ + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview The options for D3Tooltip + * + * Use D3TooltipOptions.builder() to customize tooltip options or + * D3TooltipOptions.withDefaults() for default tooltip options. + * + * @class D3TooltipOptions + * @author Brandon Clayton + */ +export class D3TooltipOptions { + + /** + * + * @param {D3TooltipOptionsBuilder} builder + */ + constructor(builder) { + Preconditions.checkArgumentInstanceOf(builder, D3TooltipOptionsBuilder); + + /** + * The tooltip background color; 'none' for no color. + * Default: 'white' + * @type {String} + */ + this.backgroundColor = builder._backgroundColor; + + /** + * The tooltip border color. + * Default: 'gray' + * @type {String} + */ + this.borderColor = builder._borderColor; + + /** + * The tooltip border width in px. + * Default: 1 + * @type {Number} width The border width in px + */ + this.borderLineWidth = builder._borderLineWidth; + + /** + * The tooltip border radius in px. + * Default: 4 + * @type {Number} radius The border radius + */ + this.borderRadius = builder._borderRadius; + + /** + * The tooltip CSS border style. + * Default: 'solid' + * @type {String} + */ + this.borderStyle = builder._borderStyle; + + /** + * The tooltip font size + * Default: 12 + * @type {Number} + */ + this.fontSize = builder._fontSize; + + /** + * The X offset of the tooltip from the data point + * Default: 2 + * @type {Number} + */ + this.offsetX = builder._offsetX; + + /** + * The Y offset of the tooltip from the data point + * Default: 10 + * @type {Number} + */ + this.offsetY = builder._offsetY; + + /** + * The bottom padding in the tooltip + * Default: 10 + * @type {Number} + */ + this.paddingBottom = builder._paddingBottom; + + /** + * The left padding in the tooltip + * Default: 10 + * @type {Number} + */ + this.paddingLeft = builder._paddingLeft; + + /** + * The right padding in the tooltip + * Default: 10 + * @type {Number} + */ + this.paddingRight = builder._paddingRight; + + /** + * The top padding in the tooltip + * Default: 10 + * @type {Number} + */ + this.paddingTop = builder._paddingTop; + + /* Make immutable */ + Object.freeze(this); + } + + /** + * Return new D3TooltipOptionsBuilder + * @returns {D3TooltipOptionsBuilder} New options builder + */ + static builder() { + return new D3TooltipOptionsBuilder(); + } + + /** + * Return new D3TooltipOptions with defaults + * @returns {D3TooltipOptions} New options with defaults + */ + static withDefaults() { + return D3TooltipOptions.builder().build(); + } + +} + +/** + * @fileoverview Builder for D3TooltipOptions + * + * Use D3TooltipOptions.builder() to get new instance of builder. + * + * @class D3TooltipOptionsBuilder + * @author Brandon Clayton + */ +export class D3TooltipOptionsBuilder { + + /** @private */ + constructor() { + /** @type {String} */ + this._backgroundColor = 'white'; + + /** @type {String} */ + this._borderColor = 'gray'; + + /** @type {Number} */ + this._borderLineWidth = 1; + + /** @type {Number} */ + this._borderRadius = 4; + + /** @type {String} */ + this._borderStyle = 'solid'; + + /** @type {Number} */ + this._fontSize = 12; + + /** @type {Number} */ + this._offsetX = 2; + + /** @type {Number} */ + this._offsetY = 8; + + /** @type {Number} */ + this._paddingBottom = 10; + + /** @type {Number} */ + this._paddingLeft = 10; + + /** @type {Number} */ + this._paddingRight = 10; + + /** @type {Number} */ + this._paddingTop = 10; + } + + /** + * Return new D3TooltipOptions + * @returns {D3TooltipOptions} New tooltip options + */ + build() { + return new D3TooltipOptions(this); + } + + /** + * Set the tooltip background color; 'none' for no color. + * Default: 'white' + * + * @param {String} color The background color + */ + backgroundColor(color) { + Preconditions.checkArgumentString(color); + this._backgroundColor = color; + return this; + } + + /** + * Set the tooltip border color. + * Default: 'gray' + * + * @param {String} color The border color + */ + borderColor(color) { + Preconditions.checkArgumentString(color); + this._borderColor = color; + return this; + } + + /** + * Set the tooltip border width in px. + * Default: 1 + * + * @param {Number} width The border width in px + */ + borderLineWidth(width) { + Preconditions.checkArgumentInteger(width); + this._borderLineWidth = width; + return this; + } + + /** + * Set the tooltip border radius in px. + * Default: 4 + * + * @param {Number} radius The border radius + */ + borderRadius(radius) { + Preconditions.checkArgumentInteger(radius); + this._borderRadius = radius; + return this; + } + + /** + * Set the tooltip CSS border style. + * Default: 'solid' + * + * @param {String} style The border style + */ + borderStyle(style) { + Preconditions.checkArgumentString(style); + this._borderStyle = style; + return this; + } + + /** + * Set the tooltip font size. + * Default: 12 + * + * @param {Number} size The font size + */ + fontSize(size) { + Preconditions.checkArgumentInteger(size); + this._fontSize = size; + return this; + } + + /** + * Set the X offset of the tooltip from the data point. + * Default: 2 + * + * @param {Number} offset The X offset + */ + offsetX(offset) { + Preconditions.checkArgumentNumber(offset); + this._offsetX = offset; + return this; + } + + /** + * Set the Y offset of the tooltip from the data point. + * Default: 10 + * + * @param {Number} offset The Y offset + */ + offsetY(offset) { + Preconditions.checkArgumentNumber(offset); + this._offsetY = offset; + return this; + } + + /** + * Set the bottom padding in the tooltip. + * Default: 10 + * + * @param {Number} pad The bottom padding in px + */ + paddingBottom(pad) { + Preconditions.checkArgumentInteger(pad); + this._paddingBottom = pad; + return this; + } + + /** + * Set the left padding in the tooltip. + * Default: 10 + * + * @param {Number} pad The left padding in px + */ + paddingLeft(pad) { + Preconditions.checkArgumentInteger(pad); + this._paddingLeft = pad; + return this; + } + + /** + * Set the right padding in the tooltip. + * Default: 10 + * + * @param {Number} pad The right padding in px + */ + paddingRight(pad) { + Preconditions.checkArgumentInteger(pad); + this._paddingRight = pad; + return this; + } + + /** + * Set the top padding in the tooltip. + * Default: 10 + * + * @param {Number} pad The top padding in px + */ + paddingTop(pad) { + Preconditions.checkArgumentInteger(pad); + this._paddingTop = pad; + return this; + } + +} diff --git a/webapp/apps/js/d3/view/D3BaseSubView.js b/webapp/apps/js/d3/view/D3BaseSubView.js new file mode 100644 index 000000000..ec5da57bf --- /dev/null +++ b/webapp/apps/js/d3/view/D3BaseSubView.js @@ -0,0 +1,186 @@ + +import { D3BaseSubViewOptions } from '../options/D3BaseSubViewOptions.js'; + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @package + * @fileoverview Create a base sub view for D3BaseView. Adds + * basic SVG structure for a plot. + * + * @class D3BaseSubView + * @author Brandon Clayton + */ +export class D3BaseSubView { + + /** + * Create new sub view. + * + * @param {HTMLElement} containerEl Container element to append sub view + * @param {D3BaseSubViewOptions} options The sub view options + */ + constructor(containerEl, options) { + Preconditions.checkArgumentInstanceOfHTMLElement(containerEl); + Preconditions.checkArgumentInstanceOf(options, D3BaseSubViewOptions); + + /** @type {HTMLElement} Container element to append sub view */ + this.containerEl = containerEl; + + /** @type {D3BaseSubViewOptions} Sub view options */ + this.options = options; + + /** @type {Number} The SVG view box height in px */ + this.svgHeight = this.options.plotHeight - + this.options.marginTop - this.options.marginBottom; + + /** @type {Number} The SVG view box width in px */ + this.svgWidth = this.options.plotWidth - + this.options.marginLeft - this.options.marginRight; + + /** @type {Number} Plot height in px */ + this.plotHeight = this.svgHeight - + this.options.paddingBottom - this.options.paddingTop; + + /** @type {Number} Plot width in px */ + this.plotWidth = this.svgWidth - + this.options.paddingLeft - this.options.paddingRight; + + /** @type {HTMLElement} The sub view element */ + this.subViewBodyEl = this._createSubView(); + + /** @type {D3BaseSubViewSVGElements} SVG elements */ + this.svg = this._createSVGStructure(); + } + + /** + * @package + * Create the SVG structure for the sub view. + * + * @returns {D3BaseSubViewSVGElements} The SVG elements. + */ + _createSVGStructure() { + let svgD3 = d3.select(this.subViewBodyEl) + .append('svg') + .attr('version', 1.1) + .attr('xmlns', 'http://www.w3.org/2000/svg') + .attr('preserveAspectRatio', 'xMinYMin meet') + .attr('viewBox', `0 0 ` + + `${this.options.plotWidth} ${this.options.plotHeight}`); + + let outerPlotD3 = svgD3.append('g') + .attr('class', 'outer-plot') + .attr('transform', `translate(` + + `${this.options.marginLeft}, ${this.options.marginTop})`); + + let outerFrameD3 = outerPlotD3.append('rect') + .attr('class', 'outer-frame') + .attr('height', this.svgHeight) + .attr('width', this.svgWidth) + .attr('fill', 'none'); + + let innerPlotD3 = outerPlotD3.append('g') + .attr('class', 'inner-plot') + .attr('transform', `translate(` + + `${this.options.paddingLeft}, ${this.options.paddingTop})`); + + let innerFrameD3 = innerPlotD3.append('rect') + .attr('class', 'inner-frame') + .attr('height', this.plotHeight) + .attr('width', this.plotWidth) + .attr('fill', 'none'); + + /* Tooltip Group */ + let tooltipD3 = innerPlotD3.append('g') + .attr('class', 'd3-tooltip'); + + let tooltipForeignObjectD3 = tooltipD3.append('foreignObject'); + let tooltipTableD3 = tooltipForeignObjectD3.append('xhtml:table') + .attr('xmlns', 'http://www.w3.org/1999/xhtml'); + + let els = new D3BaseSubViewSVGElements(); + els.innerFrameEl = innerFrameD3.node(); + els.innerPlotEl = innerPlotD3.node(); + els.outerFrameEl = outerFrameD3.node(); + els.outerPlotEl = outerPlotD3.node(); + els.svgEl = svgD3.node(); + els.tooltipEl = tooltipD3.node(); + els.tooltipForeignObjectEl = tooltipForeignObjectD3.node(); + els.tooltipTableEl = tooltipTableD3.node(); + + return els.checkElements(); + } + + /** + * @package + * Create the sub view. + * + * @returns {HTMLElement} The sub view element + */ + _createSubView() { + let subViewD3 = d3.select(this.containerEl) + .append('div') + .style('line-height', '1.2') + .attr('class', 'panel-body'); + + return subViewD3.node(); + } + +} + +/** + * @fileoverview Container class for the D3BaseSubView SVG elements + * + * @class D3BaseSubViewSVGElements + * @author Brandon Clayton + */ +export class D3BaseSubViewSVGElements { + + constructor() { + /** @type {SVGElement} The inner plot frame element */ + this.innerFrameEl = undefined; + + /** @type {SVGElement} The inner plot group element */ + this.innerPlotEl = undefined; + + /** @type {SVGElement} The outer plot frame element */ + this.outerFrameEl = undefined; + + /** @type {SVGElement} The outer plot group element */ + this.outerPlotEl = undefined; + + /** @type {SVGElement} The main SVG element */ + this.svgEl = undefined; + + /** @type {SVGElement} The tooltip group element */ + this.tooltipEl = undefined; + + /** @type {SVGElement} The tooltip foreign object element */ + this.tooltipForeignObjectEl = undefined; + + /** @type {HTMLElement} The tooltip table element */ + this.tooltipTableEl = undefined; + } + + /** + * Check that all elements are set. + * + * @returns {D3BaseSubViewSVGElements} The elements + */ + checkElements() { + for (let value of Object.values(this)) { + Preconditions.checkNotUndefined(value); + } + + Preconditions.checkStateInstanceOfSVGElement(this.innerFrameEl); + Preconditions.checkStateInstanceOfSVGElement(this.innerPlotEl); + Preconditions.checkStateInstanceOfSVGElement(this.outerFrameEl); + Preconditions.checkStateInstanceOfSVGElement(this.outerPlotEl); + Preconditions.checkStateInstanceOfSVGElement(this.svgEl); + Preconditions.checkStateInstanceOfSVGElement(this.tooltipEl); + Preconditions.checkStateInstanceOfSVGElement(this.tooltipForeignObjectEl); + Preconditions.checkStateInstanceOfHTMLElement(this.tooltipTableEl); + + return this; + } + +} diff --git a/webapp/apps/js/d3/view/D3BaseView.js b/webapp/apps/js/d3/view/D3BaseView.js new file mode 100644 index 000000000..89a41bde0 --- /dev/null +++ b/webapp/apps/js/d3/view/D3BaseView.js @@ -0,0 +1,946 @@ + +import { D3BaseSubView } from './D3BaseSubView.js'; +import { D3BaseSubViewOptions } from '../options/D3BaseSubViewOptions.js'; +import { D3BaseViewOptions } from '../options/D3BaseViewOptions.js'; + +import NshmpError from '../../error/NshmpError.js'; +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview Create a base view for plots to reside. The view + * can contain an upper and lower D3BaseSubView for multiple SVG + * plots in a single D3BaseView. + * + * Must use D3BaseView.builder() to create a D3BaseView. + * See D3BaseViewBuilder. + * + * @class D3BaseView + * @author Brandon Clayton + */ +export class D3BaseView { + + /** + * @private + * Must use D3BaseView.builder() to create new instance of D3BaseView. + * + * @param {D3BaseViewBuilder} builder The builder + */ + constructor(builder) { + Preconditions.checkArgumentInstanceOf(builder, D3BaseViewBuilder); + + /** @type {Boolean} Whether to add a grid line toogle to the header */ + this.addGridLineToggle = builder._addGridLineToggle; + + /** @type {Boolean} Whether to add a legend toggle to the header */ + this.addLegendToggle = builder._addLegendToggle; + + /** @type {Boolean} Whether to add a lower sub view */ + this.addLowerSubView = builder._addLowerSubView; + + /** @type {HTMLElement} Container element to append view */ + this.containerEl = builder._containerEl; + + /** @type {D3BaseViewOptions} View options */ + this.viewOptions = builder._viewOptions; + + /** @type {String} Track the size: 'min' || 'minCenter' || 'max' */ + this._currentViewSize = this.viewOptions.viewSizeDefault; + + /** @type {String} */ + this.resizeFullIcon = 'resize glyphicon glyphicon-resize-full'; + + /** @type {String} */ + this.resizeSmallIcon = 'resize glyphicon glyphicon-resize-small'; + + /** @type {String} The plot title text */ + this._titleText = ''; + + /** @type {Map>} The metadata */ + this._metadata = new Map(); + + let viewEls = this._createView(); + + /** @type {HTMLElement} Data view, data table element */ + this.dataTableEl = viewEls.dataTableEl; + + /** @type {HTMLElement} Metadata view element */ + this.metadataTableEl = viewEls.metadataTableEl; + + /** @type {HTMLElement} Bootstrap panel body element */ + this.viewPanelBodyEl = viewEls.viewPanelBodyEl; + + /** @type {HTMLElement} Bootstrap panel element */ + this.viewPanelEl = viewEls.viewPanelEl; + + /** @type {HTMLElement} Main view element */ + this.viewEl = viewEls.viewEl; + + /** @type {D3BaseSubView} Upper sub view */ + this.upperSubView = this._createSubView( + this.viewPanelBodyEl, + builder._upperSubViewOptions); + + /** @type {D3BaseSubView} Lower sub view */ + this.lowerSubView = undefined; + if (this.addLowerSubView) { + this.lowerSubView = this._createSubView( + this.viewPanelBodyEl, + builder._lowerSubViewOptions); + } + + /** @type {D3BaseViewHeaderElements} Elements of the view header */ + this.viewHeader = this._createViewHeader(); + + /** @type {D3BaseViewFooterElements} Elements of the view footer */ + this.viewFooter = this._createViewFooter(); + + /** @type {SVGElement} The SVG element in the data table view */ + this.dataTableSVGEl = this._updateDataMetadata(this.dataTableEl); + + /** @type {SVGElement} The SVG element in the metadata view */ + this.metadataTableSVGEl = this._updateDataMetadata(this.metadataTableEl); + + this._addEventListeners(); + } + + /** + * Return a new D3BaseViewBuilder + * + * @return {D3BaseViewBuilder} new Builder + */ + static builder() { + return new D3BaseViewBuilder(); + } + + /** + * Create the metadata table in the 'Metadata' view. + * + * @param {Map>} metadata The metadata + */ + createMetadataTable() { + let metadata = this.getMetadata(); + this.viewFooter.metadataBtnEl.removeAttribute('disabled'); + + d3.select(this.metadataTableSVGEl) + .selectAll('*') + .remove(); + + let foreignObjectD3 = d3.select(this.metadataTableSVGEl) + .append('foreignObject') + .attr('height', '100%') + .attr('width', '100%') + .style('overflow', 'scroll'); + + let tableRowsD3 = foreignObjectD3.append('xhtml:table') + .attr('class', 'table table-bordered table-hover') + .append('tbody') + .selectAll('tr') + .data([ ...metadata.keys() ]) + .enter() + .append('tr'); + + tableRowsD3.append('th') + .html((/** @type {String} */ key) => { + return key; + }); + + tableRowsD3.append('td') + .selectAll('p') + .data((/** @type {String} */ key) => { return metadata.get(key); }) + .enter() + .append('p') + .text((/** @type {String | Number} */ val) => { return val; }); + } + + /** + * Return the metadata. + * + * @returns {Map>} + */ + getMetadata() { + return new Map(this._metadata); + } + + /** + * Return the plot title HTML text + */ + getTitle() { + return new String(this._titleText); + } + + /** + * Hide the view. + */ + hide() { + this.viewEl.classList.add('hidden'); + } + + /** + * Remove the view. + */ + remove() { + this.viewEl.remove(); + } + + /** + * Show the view. + */ + show() { + this.viewEl.classList.remove('hidden'); + } + + /** + * + * @param {Map>} metadata + */ + setMetadata(metadata) { + Preconditions.checkArgumentInstanceOfMap(metadata); + for (let [key, value] of metadata) { + Preconditions.checkArgumentString(key); + Preconditions.checkArgumentArray(value); + } + + this._metadata = metadata; + } + + /** + * Set the plot title. Shows the title in the view header if present. + * + * @param {String} title The plot title + */ + setTitle(title) { + Preconditions.checkArgumentString(title); + this._titleText = title; + this.viewHeader.titleEl.innerHTML = title; + } + + /** + * Update the view size. + * + * @param {String} viewSize The view size: 'min' || 'minCenter' || 'max' + */ + updateViewSize(viewSize) { + d3.select(this.viewEl) + .classed(this.viewOptions.viewSizeMax, false) + .classed(this.viewOptions.viewSizeMin, false) + .classed(this.viewOptions.viewSizeMinCenter, false) + + switch(viewSize) { + case 'minCenter': + this._currentViewSize = 'minCenter'; + d3.select(this.viewEl).classed(this.viewOptions.viewSizeMinCenter, true); + d3.select(this.viewHeader.viewResizeEl) + .attr('class', this.resizeFullIcon); + break; + case 'min': + this._currentViewSize = 'min'; + d3.select(this.viewEl).classed(this.viewOptions.viewSizeMin, true); + d3.select(this.viewHeader.viewResizeEl) + .attr('class', this.resizeFullIcon); + break; + case 'max': + this._currentViewSize = 'max'; + d3.select(this.viewEl).classed(this.viewOptions.viewSizeMax, true); + d3.select(this.viewHeader.viewResizeEl) + .attr('class', this.resizeSmallIcon); + break; + default: + NshmpError.throwError(`View size [${viewSize}] not supported`); + } + } + + /** + * @package + * Add the D3BaseView event listeners. + */ + _addEventListeners() { + this.viewFooter.viewSwitchBtnEl + .addEventListener('click', () => { this._onPlotViewSwitch(); }); + + this.viewHeader.viewResizeEl + .addEventListener('click', () => { this._onViewResize(); }); + + this.viewHeader.titleEl + .addEventListener('input', () => { this._onTitleEntry(); }); + } + + /** + * @package + * Create a lower or upper sub view. + * + * @param {HTMLElement} el Container element to put sub view + * @param {D3BaseSubViewOptions} options Sub view options + */ + _createSubView(el, options) { + return new D3BaseSubView(el, options); + } + + /** + * @package + * Create the D3BaseView footer with 'Plot', 'Data', and + * 'Metadata' buttons and the save menu. + * + * @returns {D3BaseViewFooterElements} The HTMLElements associated with + * the footer + */ + _createViewFooter() { + let viewFooterD3 = d3.select(this.viewPanelEl) + .append('div') + .attr('class', 'panel-footer'); + + let footerToolbarD3 = viewFooterD3.append('div') + .attr('class', 'btn-toolbar footer-btn-toolbar') + .attr('role', 'toolbar'); + + let footerBtnsD3 = footerToolbarD3.selectAll('div') + .data(this._viewFooterButtons()) + .enter() + .append('div') + .attr('class', (d) => { + return `${d.footerBtnGroupColSize} footer-btn-group`; + }) + .append('div') + .attr('class', (d) => { + return `btn-group btn-group-xs btn-group-justified ${d.class}`; + }) + .attr('data-toggle', 'buttons') + .attr('role', 'group'); + + footerBtnsD3.selectAll('label') + .data((d) => { return d.btns; }) + .enter() + .append('label') + .attr('class',(d, i) => { + return `btn btn-xs btn-default footer-button ${d.class}`; + }) + .attr('value', (d) => { return d.value; }) + .attr('for', (d) => { return d.name; }) + .html((d, i) => { + return ` ${d.text}`; + }) + .each((d, i, els) => { + if (d.disabled) { + els[i].setAttribute('disabled', 'true'); + } + }) + + let saveMenuEl = this._createSaveMenu(viewFooterD3.node()); + let imageOnlyEl = saveMenuEl.querySelector('#image-only'); + let toolbarEl = footerToolbarD3.node(); + let plotBtnEl = toolbarEl.querySelector('.plot-btn'); + let dataBtnEl = toolbarEl.querySelector('.data-btn'); + let metadataBtnEl = toolbarEl.querySelector('.metadata-btn'); + + let els = new D3BaseViewFooterElements(); + els.btnToolbarEl = toolbarEl; + els.dataBtnEl = dataBtnEl; + els.footerEl = viewFooterD3.node(); + els.imageOnlyEl = imageOnlyEl; + els.metadataBtnEl = metadataBtnEl; + els.plotBtnEl = plotBtnEl; + els.saveMenuEl = saveMenuEl; + els.viewSwitchBtnEl = toolbarEl.querySelector('.plot-data-btns'); + + d3.select(plotBtnEl).classed('active', true); + + return els.checkElements(); + } + + /** + * @package + * Create the D3BaseView header with plot title, legend toggle, + * grid lines toggle, and resize toggle. + * + * @returns {D3BaseViewHeaderElements} The HTMLElements associated with + * the header + */ + _createViewHeader() { + let viewHeaderD3 = d3.select(this.viewPanelEl) + .append('div') + .attr('class', 'panel-heading') + .lower(); + + let viewTitleD3 = viewHeaderD3.append('h2') + .attr('class', 'panel-title') + .style('font-size', `${this.viewOptions.titleFontSize}px`); + + let viewTitleWidth = this.addLegendToggle && + this.addGridLineToggle ? 'calc(100% - 8em)' : + this.addLegendToggle || + this.addGridLineToggle ? 'calc(100% - 5em)' : + 'calc(100% - 2em)'; + + let plotTitleD3 = viewTitleD3.append('div') + .attr('class', 'plot-title') + .attr('contenteditable', true) + .style('width', viewTitleWidth); + + let iconsD3 = viewHeaderD3.append('span') + .attr('class', 'icon'); + + let gridLinesCheckD3 = undefined; + if (this.addGridLineToggle) { + gridLinesCheckD3 = iconsD3.append('div') + .attr('class', 'grid-line-check glyphicon glyphicon-th') + .attr('data-toggle', 'tooltip') + .attr('title', 'Click to toggle grid lines') + .property('checked', true) + .style('margin-right', '2em'); + + $(gridLinesCheckD3.node()).tooltip({container: 'body'}); + gridLinesCheckD3.node().setAttribute('checked', true); + } + + let legendCheckD3 = undefined; + if (this.addLegendToggle) { + legendCheckD3 = iconsD3.append('div') + .attr('class', 'legend-check glyphicon glyphicon-th-list') + .attr('data-toggle', 'tooltip') + .attr('title', 'Click to toggle legend') + .property('checked', true) + .style('margin-right', '2em'); + + $(legendCheckD3.node()).tooltip({container: 'body'}); + legendCheckD3.node().setAttribute('checked', true); + } + + let viewResizeD3 = iconsD3.append('div') + .attr('class',() => { + return this._currentViewSize == 'min' + ? this.resizeFullIcon : this.resizeSmallIcon; + }) + .attr('data-toggle', 'tooltip') + .attr('title', 'Click to resize'); + + $(viewResizeD3.node()).tooltip({container: 'body'}); + + let gridLinesCheckEl = gridLinesCheckD3 != undefined ? + gridLinesCheckD3.node() : undefined; + + let legendCheckEl = legendCheckD3 != undefined ? + legendCheckD3.node() : undefined; + + let els = new D3BaseViewHeaderElements(); + els.gridLinesCheckEl = gridLinesCheckEl; + els.headerEl = viewHeaderD3.node(); + els.iconsEl = iconsD3.node(); + els.legendCheckEl = legendCheckEl; + els.titleContainerEl = viewTitleD3.node(); + els.titleEl = plotTitleD3.node(); + els.viewResizeEl = viewResizeD3.node(); + + return els.checkElements(); + } + + /** + * @package + * Create the main D3BaseView. + * + * @returns {Object} The elements associated with + * the view. + */ + _createView() { + let sizeDefault = this.viewOptions.viewSizeDefault; + let viewSize = sizeDefault == 'min' ? this.viewOptions.viewSizeMin : + sizeDefault == 'minCenter' ? this.viewOptions.viewSizeMinCenter : + this.viewOptions.viewSizeMax; + + let containerD3 = d3.select(this.containerEl); + + let elD3 = containerD3.append('div') + .attr('class', 'D3View') + .classed(viewSize, true); + + let plotViewD3 = elD3.append('div') + .attr('class', 'panel panel-default'); + + let viewBodyD3 = plotViewD3.append('div') + .attr('class', 'panel-body panel-outer'); + + let dataTableD3 = viewBodyD3.append('div') + .attr('class', 'data-table panel-table hidden'); + + let metadataTableD3 = viewBodyD3.append('div') + .attr('class', 'metadata-table panel-table hidden'); + + let els = { + viewEl: elD3.node(), + viewPanelEl: plotViewD3.node(), + viewPanelBodyEl: viewBodyD3.node(), + dataTableEl: dataTableD3.node(), + metadataTableEl: metadataTableD3.node(), + }; + + return els; + } + + /** + * @package + * Create the save menu on the D3BaseView footer. + * + * @param {HTMLElement} viewFooterEl The view footer element + */ + _createSaveMenu(viewFooterEl) { + let saveAsD3 = d3.select(viewFooterEl) + .append('span') + .attr('class', 'dropup icon'); + + saveAsD3.append('div') + .attr('class', 'glyphicon glyphicon-save' + + ' footer-button dropdown-toggle') + .attr('data-toggle', 'dropdown') + .attr('aria-hashpop', true) + .attr('aria-expanded', true); + + let saveListD3 = saveAsD3.append('ul') + .attr('class', 'dropdown-menu dropdown-menu-right save-as-menu') + .attr('aria-labelledby', 'save-as-menu') + .style('min-width', 'auto'); + + let saveDataEnter = saveListD3.selectAll('li') + .data(this._saveMenuButtons()) + .enter() + .append('li'); + + saveDataEnter.filter((d) => { return d.format == 'dropdown-header'}) + .text((d) => { return d.label; }) + .attr('class', (d) => { return d.format; }) + .style('cursor', 'initial'); + + saveDataEnter.filter((d) => { return d.format != 'dropdown-header'}) + .style('padding-left', '10px') + .html((d) => { + return ` ${d.label} `; + }) + .style('cursor', 'pointer'); + + saveListD3.append('li') + .attr('role', 'seperator') + .attr('class', 'divider'); + + saveListD3.append('li') + .attr('class', 'dropdown-header') + .attr('data-type', 'image-only') + .html('Save/Preview Image Only: ' + + '') + + let saveListEl = saveListD3.node(); + Preconditions.checkStateInstanceOfHTMLElement(saveListEl); + + return saveListD3.node(); + } + + /** + * @package + * Switch between the Plot, Data, and Metadata view. + */ + _onPlotViewSwitch() { + if (event.target.hasAttribute('disabled')) return; + + let selectedView = event.target.getAttribute('value'); + + Preconditions.checkState( + selectedView == 'data' || + selectedView == 'metadata' || + selectedView == 'plot', + `Selected view [${selectedView}] is not supported`); + + this.dataTableEl.classList.toggle( + 'hidden', + selectedView != 'data'); + + this.metadataTableEl.classList.toggle( + 'hidden', + selectedView != 'metadata'); + + this.upperSubView.subViewBodyEl.classList.toggle( + 'hidden', + selectedView != 'plot'); + + if (this.addLowerSubView) { + this.lowerSubView.subViewBodyEl.classList.toggle( + 'hidden', + selectedView != 'plot'); + } + } + + /** + * @package + * Update the title text on input. + */ + _onTitleEntry() { + this._titleText = this.viewHeader.titleEl.innerHTML; + } + + /** + * @package + * Update the view size when the resize toggle is clicked. + */ + _onViewResize() { + this.viewFooter.plotBtnEl.click(); + + let nViews = d3.selectAll('.D3View') + .filter((d, i, els) => { + return !d3.select(els[i]).classed('hidden'); + }).size(); + + switch(this._currentViewSize) { + case 'max': + this._currentViewSize = nViews == 1 ? 'minCenter' : 'min'; + this.updateViewSize(this._currentViewSize); + break; + case 'min': + case 'minCenter': + this._currentViewSize = 'max'; + this.updateViewSize(this._currentViewSize); + break; + default: + NshmpError.throwError(`View size [${this._currentViewSize}] not supported`); + } + } + + /** + * @private + * + * The save menu buttons. + * + * @returns {Array} The save menu buttons + */ + _saveMenuButtons() { + let buttons = [ + { label: 'Preview Figure as:', format: 'dropdown-header', type: 'preview-figure' }, + { label: 'JPEG', format: 'jpeg', type: 'preview-figure' }, + { label: 'PNG', format: 'png', type: 'preview-figure' }, + { label: 'SVG', format: 'svg', type: 'preview-figure' }, + { label: 'Save Figure As:', format: 'dropdown-header', type: 'save-figure' }, + { label: 'JPEG', format: 'jpeg', type: 'save-figure' }, + { label: 'PNG', format: 'png', type: 'save-figure' }, + { label: 'SVG', format: 'svg', type: 'save-figure' }, + { label: 'Save Data As:', format: 'dropdown-header', type: 'save-data' }, + { label: 'CSV', format: 'csv', type: 'save-data' }, + ]; + + return buttons; + } + + /** + * @private + * Add SVG element to the data and metadata view to match the + * SVG element in the plot view. + * + * @param {HTMLElement} el The data or metadata element + */ + _updateDataMetadata(el) { + Preconditions.checkArgumentInstanceOfHTMLElement(el); + + let plotHeight = this.addLowerSubView ? + this.upperSubView.options.plotHeight + this.lowerSubView.options.plotHeight + 1: + this.upperSubView.options.plotHeight; + + let plotWidth = this.upperSubView.options.plotWidth; + + let svgD3 = d3.select(el) + .append('svg') + .attr('version', 1.1) + .attr('xmlns', 'http://www.w3.org/2000/svg') + .attr('preserveAspectRatio', 'xMinYMin meet') + .attr('viewBox', `0 0 ` + + `${plotWidth} ${plotHeight}`); + + let svgEl = svgD3.node(); + Preconditions.checkStateInstanceOfSVGElement(svgEl); + return svgEl; + } + + /** + * @package + * The D3BaseView footer buttons: Plot, Data, and Metadata. + * + * @returns {Array} The buttons + */ + _viewFooterButtons() { + let buttons = [ + { + class: 'plot-data-btns', + footerBtnGroupColSize: 'col-xs-offset-3 col-xs-6', + btnGroupColSize: 'col-xs-12', + btns: [ + { + name: 'plot', + value: 'plot', + text: 'Plot', + class: 'plot-btn', + disabled: false, + }, { + name: 'data', + value: 'data', + text: 'Data', + class: 'data-btn', + disabled: true, + }, { + name: 'metadata', + value: 'metadata', + text: 'Metadata', + class: 'metadata-btn', + disabled: true, + } + ] + } + ]; + + return buttons; + } + +} + +/** + * @fileoverview Builder for D3BaseView. + * + * Use D3BaseView.builder() for new instance of builder. + * + * @class D3BaseViewBuilder + * @author Brandon Clayton + */ +export class D3BaseViewBuilder { + + /** @private */ + constructor() { + this._setDefaultViewOptions(); + + /** @type {Boolean} */ + this._addGridLineToggle = true; + + /** @type {Boolean} */ + this._addLegendToggle = true; + + /** @type {Boolean} */ + this._addLowerSubView = false; + + /** @type {HTMLElement} */ + this._containerEl = undefined; + } + + /** + * Return a new D3BaseView + */ + build() { + Preconditions.checkNotUndefined( + this._containerEl, + 'Container element not set'); + return new D3BaseView(this); + } + + /** + * Whether to add a grid line toogle in the view's header. + * Default: true + * + * @param {Boolean} bool Whether to add the grid line toogle + */ + addGridLineToogle(bool) { + Preconditions.checkArgumentBoolean(bool); + this._addGridLineToggle = bool; + return this; + } + + /** + * Whether to add a legend toogle in the view's header. + * Default: true + * + * @param {Boolean} bool Whether to add the legend toogle + */ + addLegendToggle(bool) { + Preconditions.checkArgumentBoolean(bool); + this._addLegendToggle = bool; + return this; + } + + /** + * Whether to add a lower sub view; + * adds the ability to have an upper and lower plot in a single view. + * + * Default D3BaseSubViewOptions are applied from + * D3BaseSubViewOptions.lowerWithDefaults(). + * + * Use Builder.setLowerSubViewOptions to set custom settings. + * + * Default: false + */ + addLowerSubView(bool) { + Preconditions.checkArgumentBoolean(bool); + this._addLowerSubView = bool; + return this; + } + + /** + * Set the container element, where the view will be appended to. + * + * @param {HTMLElement} el The container element to put the view. + */ + containerEl(el) { + Preconditions.checkArgumentInstanceOfHTMLElement(el); + this._containerEl = el; + return this; + } + + /** + * Set the lower sub view options. + * + * @param {D3BaseSubViewOptions} options The lower sub view options. + */ + lowerSubViewOptions(options) { + Preconditions.checkArgumentInstanceOf(options, D3BaseSubViewOptions); + this._lowerSubViewOptions = options; + return this; + } + + /** + * Set the upper sub view options. + * + * @param {D3BaseSubViewOptions} options The upper sub view options. + */ + upperSubViewOptions(options) { + Preconditions.checkArgumentInstanceOf(options, D3BaseSubViewOptions); + this._upperSubViewOptions = options; + return this; + } + + /** + * Set the view options. + * + * @param {D3BaseViewOptions} options The view options. + */ + viewOptions(options) { + Preconditions.checkArgumentInstanceOf(options, D3BaseViewOptions); + this._viewOptions = options; + return this; + } + + /** + * @private + * Set the default view options + */ + _setDefaultViewOptions() { + /** @type {D3BaseViewOptions} */ + this._viewOptions = D3BaseViewOptions.withDefaults(); + + /** @type {D3BaseSubViewOptions} */ + this._upperSubViewOptions = D3BaseSubViewOptions.upperWithDefaults(); + + /** @type {D3BaseSubViewOptions} */ + this._lowerSubViewOptions = D3BaseSubViewOptions.lowerWithDefaults(); + } + +} + +/** + * @fileoverview Container class for D3BaseView footer elements. + * + * @class D3BaseViewFooterElements + * @author Brandon Clayton + */ +export class D3BaseViewFooterElements { + + constructor() { + /** @type {HTMLElement} The footer button toolbar element */ + this.btnToolbarEl = undefined; + + /** @type {HTMLElement} The 'Data' button element on the button toolbar */ + this.dataBtnEl = undefined; + + /** @type {HTMLElement} The footer element */ + this.footerEl = undefined; + + /** @type {HTMLElement} The image only check box element in the save menu */ + this.imageOnlyEl = undefined; + + /** @type {HTMLElement} The 'Metadata' button element on the button toolbar */ + this.metadataBtnEl = undefined; + + /** @type {HTMLElement} The 'Plot' button element on the button toolbar */ + this.plotBtnEl = undefined; + + /** @type {HTMLElement} The save menu element */ + this.saveMenuEl = undefined; + + /** @type {HTMLElement} The plot, data, and metadata container element */ + this.viewSwitchBtnEl = undefined; + } + + /** + * Check that all elements are set. + * + * @returns {D3BaseViewFooterElements} The elements + */ + checkElements() { + for (let value of Object.values(this)) { + Preconditions.checkNotUndefined(value); + } + + Preconditions.checkStateInstanceOfHTMLElement(this.btnToolbarEl); + Preconditions.checkStateInstanceOfHTMLElement(this.dataBtnEl); + Preconditions.checkStateInstanceOfHTMLElement(this.footerEl); + Preconditions.checkStateInstanceOfHTMLElement(this.imageOnlyEl); + Preconditions.checkStateInstanceOfHTMLElement(this.metadataBtnEl); + Preconditions.checkStateInstanceOfHTMLElement(this.plotBtnEl); + Preconditions.checkStateInstanceOfHTMLElement(this.saveMenuEl); + Preconditions.checkStateInstanceOfHTMLElement(this.viewSwitchBtnEl); + + return this; + } + +} + +/** + * @fileoverview Container class for D3BaseView header elements. + * + * @class D3BaseViewHeaderElements + * @author Brandon Clayton + */ +export class D3BaseViewHeaderElements { + + constructor() { + /** @type {HTMLElement} The grid line check icon element */ + this.gridLinesCheckEl = undefined; + + /** @type {HTMLElement} The header element */ + this.headerEl = undefined; + + /** @type {HTMLElement} The icons element */ + this.iconsEl = undefined; + + /** @type {HTMLElement} The legend check icon element */ + this.legendCheckEl = undefined; + + /** @type {HTMLElement} The title container element */ + this.titleContainerEl = undefined; + + /** @type {HTMLElement} The title element */ + this.titleEl = undefined; + + /** @type {HTMLElement} The resize icon element */ + this.viewResizeEl = undefined; + } + + /** + * Check that all elements are set. + * + * @returns {D3BaseViewHeaderElements} The elements + */ + checkElements() { + for (let value of Object.values(this)) { + Preconditions.checkNotUndefined(value); + } + + Preconditions.checkStateInstanceOfHTMLElement(this.gridLinesCheckEl); + Preconditions.checkStateInstanceOfHTMLElement(this.headerEl); + Preconditions.checkStateInstanceOfHTMLElement(this.iconsEl); + Preconditions.checkStateInstanceOfHTMLElement(this.legendCheckEl); + Preconditions.checkStateInstanceOfHTMLElement(this.titleContainerEl); + Preconditions.checkStateInstanceOfHTMLElement(this.titleEl); + Preconditions.checkStateInstanceOfHTMLElement(this.viewResizeEl); + + return this; + } + +} diff --git a/webapp/apps/js/d3/view/D3LineSubView.js b/webapp/apps/js/d3/view/D3LineSubView.js new file mode 100644 index 000000000..3121be74d --- /dev/null +++ b/webapp/apps/js/d3/view/D3LineSubView.js @@ -0,0 +1,205 @@ + +import { D3BaseSubView, D3BaseSubViewSVGElements } from './D3BaseSubView.js'; +import { D3LineSubViewOptions } from '../options/D3LineSubViewOptions.js'; + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @package + * @fileoverview Create a sub view for a D3LineView. Adds the + * line plot SVG structure for a line plot. + * + * @class D3LineSubView + * @extends D3BaseSubView + * @author Brandon Clayton + */ +export class D3LineSubView extends D3BaseSubView { + + /** + * Create a new sub view for D3LineView + * + * @param {HTMLElement} containerEl Container element to append sub view + * @param {D3LineSubViewOptions} options The sub view options + */ + constructor(containerEl, options) { + super(containerEl, options); + + /* Update types */ + /** @type {D3LineSubViewSVGElements} Line plot SVG elements */ + this.svg; + + /** @type {D3LineSubViewOptions} Sub view options for line plot */ + this.options; + + } + + /** + * @override + * @package + * Create the sub view SVG structure for a line plot. + * + * @returns {D3LineSubViewSVGElements} The SVG elements + */ + _createSVGStructure() { + let svg = super._createSVGStructure(); + let svgEl = svg.svgEl; + let outerPlotEl = svg.outerPlotEl; + let innerPlotEl = svg.innerPlotEl; + let tooltipEl = svg.tooltipEl; + + /* Grid Lines */ + let gridLinesD3 = d3.select(innerPlotEl) + .append('g') + .attr('class', 'grid-lines'); + let xGridLinesD3 = gridLinesD3.append('g') + .attr('class', 'x-grid-lines'); + let yGridLinesD3 = gridLinesD3.append('g') + .attr('class', 'y-grid-lines'); + + /* X Axis */ + let xAxisD3 = d3.select(innerPlotEl) + .append('g') + .attr('class', 'x-axis'); + let xTickMarksD3 = xAxisD3.append('g') + .attr('class', 'x-tick-marks'); + let xLabelD3 = xAxisD3.append('text') + .attr('class', 'x-label') + .attr('fill', 'black'); + + /* Y Axis */ + let yAxisD3 = d3.select(innerPlotEl) + .append('g') + .attr('class', 'y-axis'); + let yTickMarksD3 = yAxisD3.append('g') + .attr('class', 'y-tick-marks'); + let yLabelD3 = yAxisD3.append('text') + .attr('class', 'y-label') + .attr('fill', 'black') + .attr('transform', 'rotate(-90)'); + + /* Data Container Group */ + let dataContainerD3 = d3.select(innerPlotEl) + .append('g') + .attr('class', 'data-container-group'); + + /* Legend Group */ + let legendD3 = d3.select(innerPlotEl) + .append('g') + .attr('class', 'legend') + .style('line-height', '1.5'); + + let legendForeignObjectD3 = legendD3.append('foreignObject'); + + let legendTableD3 = legendForeignObjectD3 + .append('xhtml:table') + .attr('xmlns', 'http://www.w3.org/1999/xhtml'); + + d3.select(tooltipEl).raise(); + + let els = new D3LineSubViewSVGElements(); + els.dataContainerEl = dataContainerD3.node(); + els.gridLinesEl = gridLinesD3.node(); + els.legendEl = legendD3.node(); + els.legendForeignObjectEl = legendForeignObjectD3.node(); + els.legendTableEl = legendTableD3.node(); + els.innerFrameEl = svg.innerFrameEl; + els.innerPlotEl = innerPlotEl; + els.outerFrameEl = svg.outerFrameEl; + els.outerPlotEl = outerPlotEl; + els.svgEl = svgEl; + els.tooltipEl = tooltipEl; + els.tooltipForeignObjectEl = svg.tooltipForeignObjectEl; + els.tooltipTableEl = svg.tooltipTableEl; + els.xAxisEl = xAxisD3.node(); + els.xGridLinesEl = xGridLinesD3.node(); + els.xLabelEl = xLabelD3.node(); + els.xTickMarksEl = xTickMarksD3.node(); + els.yAxisEl = yAxisD3.node(); + els.yGridLinesEl = yGridLinesD3.node(); + els.yLabelEl = yLabelD3.node(); + els.yTickMarksEl = yTickMarksD3.node(); + + return els.checkElements(); + } + +} + +/** + * @fileoverview Container class for the D3LineSubView SVG elements + * + * @class D3LineSubViewSVGElements + * @extends D3BaseSubViewSVGElements + * @author Brandon Clayton + */ +export class D3LineSubViewSVGElements extends D3BaseSubViewSVGElements { + + constructor() { + super(); + + /** @type {SVGElement} The data group element */ + this.dataContainerEl = undefined; + + /** @type {SVGElement} The grid lines group element */ + this.gridLinesEl = undefined; + + /** @type {SVGElement} The legend group element*/ + this.legendEl = undefined; + + /** @type {SVGElement} The legend foreign object element */ + this.legendForeignObjectEl = undefined; + + /** @type {HTMLElement} The table element*/ + this.legendTableEl = undefined; + + /** @type {SVGElement} The X axis group element */ + this.xAxisEl = undefined; + + /** @type {SVGElement} The X axis grid lines group element */ + this.xGridLinesEl = undefined; + + /** @type {SVGElement} The X label text element */ + this.xLabelEl = undefined; + + /** @type {SVGElement} The X axis tick marks group element */ + this.xTickMarksEl = undefined; + + /** @type {SVGElement} The Y axis group element */ + this.yAxisEl = undefined; + + /** @type {SVGElement} The Y axis grid lines group element */ + this.yGridLinesEl = undefined; + + /** @type {SVGElement} The Y label text element */ + this.yLabelEl = undefined; + + /** @type {SVGElement} The Y axis tick marks group element */ + this.yTickMarksEl = undefined; + } + + /** + * @override + * Check the elements. + * + * @returns {D3LineSubViewSVGElements} The elements + */ + checkElements() { + super.checkElements(); + + Preconditions.checkStateInstanceOfSVGElement(this.dataContainerEl); + Preconditions.checkStateInstanceOfSVGElement(this.gridLinesEl); + Preconditions.checkStateInstanceOfSVGElement(this.legendEl); + Preconditions.checkStateInstanceOfSVGElement(this.legendForeignObjectEl); + Preconditions.checkStateInstanceOfHTMLElement(this.legendTableEl); + Preconditions.checkStateInstanceOfSVGElement(this.xAxisEl); + Preconditions.checkStateInstanceOfSVGElement(this.xGridLinesEl); + Preconditions.checkStateInstanceOfSVGElement(this.xLabelEl); + Preconditions.checkStateInstanceOfSVGElement(this.xTickMarksEl); + Preconditions.checkStateInstanceOfSVGElement(this.yAxisEl); + Preconditions.checkStateInstanceOfSVGElement(this.yGridLinesEl); + Preconditions.checkStateInstanceOfSVGElement(this.yLabelEl); + Preconditions.checkStateInstanceOfSVGElement(this.yTickMarksEl); + + return this; + } + +} diff --git a/webapp/apps/js/d3/view/D3LineView.js b/webapp/apps/js/d3/view/D3LineView.js new file mode 100644 index 000000000..d078ce2be --- /dev/null +++ b/webapp/apps/js/d3/view/D3LineView.js @@ -0,0 +1,488 @@ + +import { D3BaseView, D3BaseViewFooterElements } from './D3BaseView.js'; +import { D3BaseViewBuilder } from './D3BaseView.js'; +import { D3LineData } from '../data/D3LineData.js'; +import { D3LineSeriesData } from '../data/D3LineSeriesData.js'; +import { D3LineSubView } from './D3LineSubView.js'; +import { D3LineSubViewOptions } from '../options/D3LineSubViewOptions.js'; +import { D3LineViewOptions } from '../options/D3LineViewOptions.js'; +import { D3XYPair } from '../data/D3XYPair.js'; + +import { Preconditions } from '../../error/Preconditions.js'; + +/** + * @fileoverview Create a view for line plots. The view can + * contain an upper and lower D3LineSubView for multiple SVG + * plots in a single D3LineView. + * + * Must use D3LineView.builder() to create a D3LineView instance. + * See D3LineViewBuilder. + * + * @class D3LineView + * @extends D3BaseView + * @author Brandon Clayton + */ +export class D3LineView extends D3BaseView { + + /** + * @private + * Must use D3LineView.builder() to create new instance of D3LineView + * + * @param {D3LineViewBuilder} builder The builder + */ + constructor(builder) { + super(builder); + + /** @type {Array} The data that will be saved to file */ + this._saveData = undefined; + + /* Update types */ + /** @type {D3LineSubView} Lower sub view */ + this.lowerSubView; + + /** @type {D3LineSubView} Upper sub view */ + this.upperSubView; + + /** @type {D3LineViewFooterElements} The Footer elements */ + this.viewFooter; + + /** @type {D3LineViewOptions} */ + this.viewOptions; + } + + /** + * @override + * Return a new D3LineViewBuilder + * + * @returns {D3LineViewBuilder} new Builder + */ + static builder() { + return new D3LineViewBuilder(); + } + + /** + * Create the data table in the 'Data' view. + * + * @param {...D3LineData} lineDatas The line datas + */ + createDataTable(...lineDatas) { + Preconditions.checkArgumentArrayInstanceOf(lineDatas, D3LineData); + this.viewFooter.dataBtnEl.removeAttribute('disabled'); + + d3.select(this.dataTableSVGEl) + .selectAll('*') + .remove(); + + let foreignObjectD3 = d3.select(this.dataTableSVGEl) + .append('foreignObject') + .attr('height', '100%') + .attr('width', '100%') + .style('overflow', 'scroll') + .style('padding', '5px'); + + for (let lineData of lineDatas) { + let divD3 = foreignObjectD3.append('xhtml:div'); + + divD3.append('h3').text(lineData.subView.options.label); + + let tableD3 = divD3.append('table') + .attr('class', 'table table-bordered table-condensed') + .append('tbody'); + + let tableEl = tableD3.node(); + + for (let series of lineData.series) { + this._addSeriesToDataTable(tableEl, lineData, series); + } + } + + } + + /** + * Get the D3LineData that will be saved to a file(s). + * + * @return {Array} The line data to save + */ + getSaveData() { + return this._saveData; + } + + /** + * Get the X axis scale based on the D3LineViewOptions.synXAxisScale + * and D3LineSubViewOptions.xAxisScale. + * + * @param {D3LineSubView} subView + * @returns {String} The X axis scale: 'log' || 'linear' + */ + getXAxisScale(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + return this.viewOptions.syncXAxisScale ? this.viewOptions.xAxisScale : + subView.options.xAxisScale; + } + /** + * Get the Y axis scale based on the D3LineViewOptions.synYAxisScale + * and D3LineSubViewOptions.yAxisScale. + * + * @param {D3LineSubView} subView + * @returns {String} The Y axis scale: 'log' || 'linear' + */ + getYAxisScale(subView) { + Preconditions.checkArgumentInstanceOf(subView, D3LineSubView); + + return this.viewOptions.syncYAxisScale ? this.viewOptions.yAxisScale : + subView.options.yAxisScale; + } + + /** + * Set the D3LineDatas that will be saved to a file. + * @param {...D3LineData} lineDatas + */ + setSaveData(...lineDatas) { + Preconditions.checkArgumentArrayInstanceOf(lineDatas, D3LineData); + this._saveData = lineDatas; + } + + /** + * Add the Array to the data table. + * + * @param {D3LineData} lineData The line data + * @param {HTMLElement} tableEl The table element + * @param {D3LineSeriesData} series The series data + * @param {String} label The X/Y label + * @param {String} axis The axis: 'x' || 'y' + */ + _addDataToDataTable(lineData, tableEl, series, label, axis) { + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentInstanceOfHTMLElement(tableEl); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + Preconditions.checkArgumentString(label); + Preconditions.checkArgument( + axis == 'x' || axis == 'y', + `Axis [${axis}] not supported for data table`); + + let rowD3 = d3.select(tableEl).append('tr'); + rowD3.append('th') + .attr('nowrap', true) + .text(label); + + let fractionDigits = lineData.subView + .options[`${axis}ExponentFractionDigits`]; + + let toExponent = lineData.subView.options[`${axis}ValueToExponent`]; + + rowD3.selectAll('td') + .data(series.data) + .enter() + .append('td') + .text((/** @type {D3XYPair} */ xyPair) => { + Preconditions.checkStateInstanceOf(xyPair, D3XYPair); + + let val = toExponent ? + xyPair[axis].toExponential(fractionDigits) : + xyPair[axis]; + + return xyPair[`${axis}String`] || val; + }); + } + + /** + * Add the D3LineSeriesData to the data table. + * + * @param {HTMLElement} tableEl The table element + * @param {D3LineData} lineData The line data + * @param {D3LineSeriesData} series The series data + */ + _addSeriesToDataTable(tableEl, lineData, series) { + Preconditions.checkArgumentInstanceOfHTMLElement(tableEl); + Preconditions.checkArgumentInstanceOf(lineData, D3LineData); + Preconditions.checkArgumentInstanceOf(series, D3LineSeriesData); + + d3.select(tableEl) + .append('tr') + .append('th') + .attr('nowrap', true) + .attr('colspan', series.data.length + 1) + .append('h4') + .style('margin', '0') + .text(series.lineOptions.label); + + let xLabel = lineData.subView.options.xLabel; + this._addDataToDataTable(lineData, tableEl, series, xLabel, 'x'); + + let yLabel = lineData.subView.options.yLabel; + this._addDataToDataTable(lineData, tableEl, series, yLabel, 'y'); + } + + /** + * @private + * Create the X axis buttons on the view's footer. + */ + _addXAxisBtns(footer) { + let xAxisBtnEl = footer.btnToolbarEl.querySelector('.x-axis-btns'); + let xLinearBtnEl = xAxisBtnEl.querySelector('.x-linear-btn'); + let xLogBtnEl = xAxisBtnEl.querySelector('.x-log-btn'); + + if (this.viewOptions.syncXAxisScale) { + let xScaleEl = this.viewOptions.xAxisScale == 'log' ? + xLogBtnEl : xLinearBtnEl; + + xScaleEl.classList.add('active'); + } else { + let xScaleEl = this.upperSubView.options.xAxisScale == 'log' ? + xLogBtnEl : xLinearBtnEl; + + xScaleEl.classList.add('active'); + } + + let els = { + xAxisBtnEl: xAxisBtnEl, + xLinearBtnEl: xLinearBtnEl, + xLogBtnEl: xLogBtnEl + }; + return els; + } + + /** + * @private + * Create the Y axis buttons on the view's footer + */ + _addYAxisBtns(footer) { + let yAxisBtnEl = footer.btnToolbarEl.querySelector('.y-axis-btns'); + let yLinearBtnEl = yAxisBtnEl.querySelector('.y-linear-btn'); + let yLogBtnEl = yAxisBtnEl.querySelector('.y-log-btn'); + + if (this.viewOptions.syncYAxisScale) { + let yScaleEl = this.viewOptions.yAxisScale == 'log' ? + yLogBtnEl : yLinearBtnEl; + + yScaleEl.classList.add('active'); + } else { + let yScaleEl = this.upperSubView.options.yAxisScale == 'log' ? + yLogBtnEl : yLinearBtnEl; + + yScaleEl.classList.add('active'); + } + + let els = { + yAxisBtnEl: yAxisBtnEl, + yLinearBtnEl: yLinearBtnEl, + yLogBtnEl: yLogBtnEl + }; + return els; + } + + /** + * @override + * @package + * Create the sub views. + * + * @param {HTMLElement} el Container element to append sub view + * @param {D3LineSubViewOptions} options Sub view options + * @returns {D3LineSubView} The line sub view + */ + _createSubView(el, options) { + return new D3LineSubView(el, options); + } + + /** + * @override + * @package + * Create the D3LineView footer. + * + * @return {D3LineViewFooterElements} The elements associated with the footer + */ + _createViewFooter() { + let footer = super._createViewFooter(); + + let xAxisEls = this._addXAxisBtns(footer); + let yAxisEls = this._addYAxisBtns(footer); + + let els = new D3LineViewFooterElements(); + els.btnToolbarEl = footer.btnToolbarEl; + els.dataBtnEl = footer.dataBtnEl; + els.footerEl = footer.footerEl; + els.imageOnlyEl = footer.imageOnlyEl; + els.metadataBtnEl = footer.metadataBtnEl; + els.plotBtnEl = footer.plotBtnEl; + els.saveMenuEl = footer.saveMenuEl; + els.viewSwitchBtnEl = footer.viewSwitchBtnEl; + + els.xAxisBtnEl = xAxisEls.xAxisBtnEl; + els.xLinearBtnEl = xAxisEls.xLinearBtnEl; + els.xLogBtnEl = xAxisEls.xLogBtnEl; + els.yAxisBtnEl = yAxisEls.yAxisBtnEl; + els.yLinearBtnEl = yAxisEls.yLinearBtnEl; + els.yLogBtnEl = yAxisEls.yLogBtnEl; + + + return els.checkElements(); + } + + /** + * @override + * The view footer buttons + */ + _viewFooterButtons() { + let buttons = [ + { + class: 'x-axis-btns', + footerBtnGroupColSize: 'col-xs-3', + btnGroupColSize: 'col-xs-12', + btns: [ + { + name: 'x-axis-x', + value: 'linear', + text: 'X: Linear', + class: 'x-linear-btn', + disabled: this.viewOptions.disableXAxisBtns, + }, { + name: 'x-axis-y', + value: 'log', + text: 'X: Log', + class: 'x-log-btn', + disabled: this.viewOptions.disableXAxisBtns, + } + ] + }, { + class: 'y-axis-btns', + footerBtnGroupColSize: 'col-xs-3', + btnGroupColSize: 'col-xs-12', + btns: [ + { + name: 'y-axis-x', + value: 'linear', + text: 'Y: Linear', + class: 'y-linear-btn', + disabled: this.viewOptions.disableYAxisBtns, + }, { + name: 'y-axis-y', + value: 'log', + text: 'Y: Log', + class: 'y-log-btn', + disabled: this.viewOptions.disableYAxisBtns, + } + ] + }, { + class: 'plot-data-btns', + footerBtnGroupColSize: 'col-xs-6', + btnGroupColSize: 'col-xs-12', + btns: [ + { + name: 'plot', + value: 'plot', + text: 'Plot', + class: 'plot-btn', + disabled: false, + }, { + name: 'data', + value: 'data', + text: 'Data', + class: 'data-btn', + disabled: true, + }, { + name: 'metadata', + value: 'metadata', + text: 'Metadata', + class: 'metadata-btn', + disabled: true, + } + ] + } + ]; + + return buttons; + } + +} + +/** + * @fileoverview Builder for D3LineView. + * + * Use D3LineView.builder() for new instance of builder. + * + * @class D3LineViewBuilder + * @extends D3BaseViewBuilder + * @author Brandon Clayton + */ +export class D3LineViewBuilder extends D3BaseViewBuilder { + + /** @private */ + constructor() { + super(); + } + + /** + * Returns a new D3LineView instance + */ + build() { + return new D3LineView(this); + } + + /** + * @override + * @private + * Set the default line view options + */ + _setDefaultViewOptions() { + /** @type {D3LineViewOptions} */ + this._viewOptions = D3LineViewOptions.withDefaults(); + + /** @type {D3LineSubViewOptions} */ + this._upperSubViewOptions = D3LineSubViewOptions.upperWithDefaults(); + + /** @type {D3LineSubViewOptions} */ + this._lowerSubViewOptions = D3LineSubViewOptions.lowerWithDefaults(); + } + +} + +/** + * @fileoverview Container class for D3LineView footer elements + * + * @class D3LineViewFooterElements + * @extends D3BaseViewFooterElements + * @author Brandon Clayton + */ +export class D3LineViewFooterElements extends D3BaseViewFooterElements { + + constructor() { + super(); + + /** @type {HTMLElement} The X axis button element */ + this.xAxisBtnEl = undefined; + + /** @type {HTMLElement} The X axis linear button element */ + this.xLinearBtnEl = undefined; + + /** @type {HTMLElement} The X axis log button element */ + this.xLogBtnEl = undefined; + + /** @type {HTMLElement} The Y axis button element */ + this.yAxisBtnEl = undefined; + + /** @type {HTMLElement} The Y axis linear button element */ + this.yLinearBtnEl = undefined; + + /** @type {HTMLElement} The Y axis log button element */ + this.yLogBtnEl = undefined; + } + + /** + * @override + * Check that the elements are set + */ + checkElements() { + super.checkElements(); + + Preconditions.checkStateInstanceOfHTMLElement(this.xAxisBtnEl); + Preconditions.checkStateInstanceOfHTMLElement(this.xLinearBtnEl); + Preconditions.checkStateInstanceOfHTMLElement(this.xLogBtnEl); + Preconditions.checkStateInstanceOfHTMLElement(this.yAxisBtnEl); + Preconditions.checkStateInstanceOfHTMLElement(this.yLinearBtnEl); + Preconditions.checkStateInstanceOfHTMLElement(this.yLogBtnEl); + + return this; + } + +} diff --git a/webapp/apps/js/error/NshmpError.js b/webapp/apps/js/error/NshmpError.js new file mode 100644 index 000000000..793a80c54 --- /dev/null +++ b/webapp/apps/js/error/NshmpError.js @@ -0,0 +1,253 @@ + +/** + * @fileoverview Error class that will create a Bootstrap modal + * with the error message. + * + * @extends Error + * @author Brandon Clayton + */ +export default class NshmpError extends Error { + + /** + * Create a Boostrap modal with an error message. + * + * @param {String} errorMessage The error message to display. + */ + constructor(errorMessage) { + super(errorMessage); + + if (errorMessage instanceof NshmpError) { + console.error(errorMessage); + return; + } + + this.message = errorMessage; + try { + let els = this._createErrorModal(); + this.el = els.get('el'); + this.headerEl = els.get('headerEl'); + this.bodyEl = els.get('bodyEl'); + this.footerEl = els.get('footerEl'); + + $(this.el).modal({backdrop: 'static'}); + + $(this.el).on('hidden.bs.modal', (event) => { + d3.select(this.el).remove(); + }); + } catch (err) { + alert(`${err} \n ${this.message}`); + } + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, NshmpError); + } + } + + /** + * Check an array of web service responses to see if any web service + * response has "status" = "error". + * + * If a web service has an error a NshmpError is thrown + * + * If a web service response has status error and the + * supplied plot has a method clearData, it will be invoked + * for the upper panel and lower panel. + * + * @param {Array} responses The web service responses + * @param {D3LinePlot || D3GeoDeagg} plots The plots to clear + */ + static checkResponses(responses, ...plots) { + let errorMessage = ''; + let hasError = false; + + for (let response of responses) { + let status = response.status; + if (status == 'error') { + hasError = true; + errorMessage += `

${response.message}

\n`; + } + } + + if (hasError) { + for (let plot of plots) { + if (plot.clearData) { + plot.clearData(plot.upperPanel); + plot.clearData(plot.lowerPanel); + } + } + + throw new NshmpError(errorMessage); + } + } + + /** + * Check a web service response to see for "status" = "error". + * + * If a web service has an error, a native JavaScript + * Error is thrown to allow a catch method to catch it. + * + * If a web service response has status error and the + * supplied plot has a method clearData, it will be invoked + * for the upper panel and lower panel. + * + * @param {Object} response + * @param {D3LinePLot || D3GeoDeagg} plots The plots to clear + */ + static checkResponse(response, ...plots) { + return NshmpError.checkResponses([response], ...plots); + } + + /** + * Convience method to throw a new NshmpError. + * If the error message equals 'cancal' an error is not thrown, + * useful when canceling a Promise. + * + * @param {String} errorMessage The exception message to use + */ + static throwError(errorMessage) { + if (errorMessage instanceof NshmpError) { + console.error(errorMessage); + return + } + + if (errorMessage == 'cancel') return; + + throw new NshmpError(errorMessage); + } + + /** + * Create the Bootstrap modal + */ + _createErrorModal() { + /* Modal */ + let overlayD3 = d3.select('body') + .append('div') + .attr('class', 'modal error-modal') + .attr('id', 'error-modal') + .attr('tabindex', '-1') + .attr('role', 'dialog'); + + /* Modal content */ + let contentD3 = overlayD3.append('div') + .attr('class', 'modal-dialog vertical-center') + .attr('role', 'document') + .style('display', 'grid') + .style('margin', '0 auto') + .append('div') + .attr('class', 'modal-content') + .style('overflow', 'hidden'); + + let contentEl = contentD3.node(); + let el = overlayD3.node(); + + let headerEl = this._createModalHeader(contentEl); + let bodyEl = this._createModalBody(contentEl); + let footerEl = this._createModalFooter(contentEl); + + let els = new Map(); + els.set('el', el); + els.set('headerEl', headerEl); + els.set('bodyEl', bodyEl); + els.set('footerEl', footerEl); + + return els; + } + + /** + * Add modal footer with collapsible panel with stack trace + * @param {HTMLElement} modalEl + */ + _createModalFooter(modalEl) { + let footerD3 = d3.select(modalEl) + .append('div') + .attr('class', 'panel-footer'); + + let footerTextD3 = footerD3.append('div') + .attr('role', 'button') + .attr('data-toggle', 'collapse') + .attr('data-parent', '#error-modal') + .attr('href', '#stack-trace') + .attr('aria-expanded', 'false') + .attr('aria-controls', 'stack-trace') + .text('Stack trace'); + + let chevronD3 = footerTextD3.append('span') + .attr('class', 'pull-right glyphicon glyphicon-chevron-down'); + + let collapseD3 = d3.select(modalEl) + .append('div') + .attr('class', 'panel-collapse collapse') + .attr('id', 'stack-trace') + .attr('role', 'tabpanel'); + + collapseD3.append('div') + .attr('class', 'panel-body') + .text(this.stack); + + let collapseEl = collapseD3.node(); + let chevronEl = chevronD3.node(); + this._collapseStackTraceListener(collapseEl, chevronEl); + + return footerD3.node(); + } + + /** + * Set event listeners for the collapsing panel + * @param {HTMLElement} collapseEl + * @param {HTMLElement} chevronEl + */ + _collapseStackTraceListener(collapseEl, chevronEl) { + let chevronDown = 'glyphicon-chevron-down'; + let chevronUp = 'glyphicon-chevron-up'; + + $(collapseEl).on('show.bs.collapse', () => { + chevronEl.classList.remove(chevronDown); + chevronEl.classList.add(chevronUp); + }); + + $(collapseEl).on('hide.bs.collapse', () => { + chevronEl.classList.remove(chevronUp); + chevronEl.classList.add(chevronDown); + }); + } + + /** + * Create the modal header + * @param {HTMLElement} modalEl The modal element + */ + _createModalHeader(modalEl) { + let headerD3 = d3.select(modalEl) + .append('div') + .attr('class', 'modal-header') + .style('background-color', '#EF9A9A'); + + headerD3.append('button') + .attr('type', 'button') + .attr('class', 'btn close') + .attr('data-dismiss', 'modal') + .style('opacity', '0.5') + .append('span') + .attr('class', 'glyphicon glyphicon-remove') + + headerD3.append('h4') + .attr('class', 'modal-title') + .text('Error'); + + return headerD3.node(); + } + + /** + * Create the modal body + * @param {HTMLElement} modalEl The model element + */ + _createModalBody(modalEl) { + let bodyD3 = d3.select(modalEl) + .append('div') + .attr('class', 'modal-body') + .style('word-wrap', 'break-word') + .html(this.message); + + return bodyD3.node(); + } + +} diff --git a/webapp/apps/js/error/Preconditions.js b/webapp/apps/js/error/Preconditions.js new file mode 100644 index 000000000..d374d5546 --- /dev/null +++ b/webapp/apps/js/error/Preconditions.js @@ -0,0 +1,417 @@ + +import NshmpError from './NshmpError.js'; + +/** + * @fileoverview Static convenience methods to check wether a method or + * constructor was invoked correctly. + * + * If a precondition is not statisfied a NshmpError is thrown. + * + * @class Preconditions + * @author Brandon Clayton + */ +export class Preconditions { + + /** @private */ + constructor() {} + + /** + * Ensures the truth of an expression. + * + * @param {Boolean} expression Expression to check + * @param {String} errorMessage The exception message to use if the + * expression fails + */ + static checkArgument(expression, errorMessage) { + if (!expression) { + throw new NshmpError(`IllegalArgumentException: ${errorMessage}`); + } + } + + /** + * Check whether an argument is an array. + * + * @param {Array} arr The array to test + * @param {String=} errorMessage An optional error message to show + */ + static checkArgumentArray(arr, errorMessage = 'Must be an array') { + Preconditions.checkArgument(Array.isArray(arr), errorMessage); + } + + /** + * Check whether an argument is an array and all elements + * inside are of specific type. + * + * @param {Array} arr Array to check + * @param {Object} type Type inside array to check + */ + static checkArgumentArrayInstanceOf(arr, type) { + Preconditions.checkArgumentArray(arr); + + for (let val of arr) { + Preconditions.checkArgumentInstanceOf(val, type); + } + } + + /** + * Check whether an array is of certain length. + * + * @param {Array} arr The array to test + * @param {Number} length The length the array should be + */ + static checkArgumentArrayLength(arr, length) { + Preconditions.checkArgumentArray(arr); + Preconditions.checkArgumentInteger(length); + Preconditions.checkArgument(arr.length == length); + } + + /** + * Check whether an argument is an array and all elements inside the + * array are of a specificed type. + * + * @param {Array} arr The array to test + * @param {String} type The type of data inside the array + * @param {String=} errorMessage An optional error message to show + */ + static checkArgumentArrayOf(arr, type, errorMessage = 'Must be an array') { + Preconditions.checkArgumentArray(arr, errorMessage); + + for (let data of arr) { + Preconditions.checkArgumentTypeOf(data, type); + } + } + + /** + * Check whether an argument is a boolean. + * + * @param {Boolean} val The value to test + */ + static checkArgumentBoolean(val) { + Preconditions.checkArgumentTypeOf(val, 'boolean'); + } + + /** + * Check whether an argument is a integer. + * + * @param {Number} val The value to test + */ + static checkArgumentInteger(val) { + Preconditions.checkArgument(Number.isInteger(val), 'Must be an integer'); + } + + /** + * Check whether an argument is a certain instance of a type. + * + * @param {Object} val The value to check + * @param {Object} type The type of instance the value should be + */ + static checkArgumentInstanceOf(val, type) { + Preconditions.checkArgument( + val instanceof type, + `Must be instance of [${type.name}]`); + } + + /** + * Check whether an argument is a HTMLElement + * + * @param {Object} val The value to check + * @param {Object} type The type of instance the value should be + */ + static checkArgumentInstanceOfHTMLElement(val) { + Preconditions.checkArgumentInstanceOf(val, HTMLElement); + } + + /** + * Check whether an argument is a Map + * + * @param {Object} val The value to check + * @param {Object} type The type of instance the value should be + */ + static checkArgumentInstanceOfMap(val) { + Preconditions.checkArgumentInstanceOf(val, Map); + } + + /** + * Check whether an argument is a Set + * + * @param {Object} val The value to check + * @param {Object} type The type of instance the value should be + */ + static checkArgumentInstanceOfSet(val) { + Preconditions.checkArgumentInstanceOf(val, Set); + } + + /** + * Check whether an argument is a SVGElement + * + * @param {Object} val The value to check + * @param {Object} type The type of instance the value should be + */ + static checkArgumentInstanceOfSVGElement(val) { + Preconditions.checkArgumentInstanceOf(val, SVGElement); + } + + /** + * Check whether an argument is a number. + * + * @param {Number} val The value to test + */ + static checkArgumentNumber(val) { + Preconditions.checkArgumentTypeOf(val, 'number'); + } + + /** + * Check whether an argument is an object. + * + * @param {Object} val The value to test + */ + static checkArgumentObject(val) { + Preconditions.checkArgumentTypeOf(val, 'object'); + } + + /** + * Check whether a property exists in an object. + * + * @param {Object} obj The object to check + * @param {String} property The property to see if exists + */ + static checkArgumentObjectProperty(obj, property) { + Preconditions.checkArgumentObject(obj); + Preconditions.checkArgumentString(property); + Preconditions.checkArgument( + obj.hasOwnProperty(property), + `Must have property [${property}] in object`); + } + + /** + * Check whether an argument is a string. + * + * @param {String} val The string to test + */ + static checkArgumentString(val) { + Preconditions.checkArgumentTypeOf(val, 'string'); + } + + /** + * Test whether an argument is of a specific type. + * + * @param {Object} val The value to test + * @param {String} type The type the value should be + */ + static checkArgumentTypeOf(val, type) { + Preconditions.checkArgument(typeof(val) == type, `Must be of type [${type}]`); + } + + /** + * Check whether a value is null. + * + * @param {Object} val The value to test + * @param {String=} errorMessage Optional error message + */ + static checkNotNull(val, errorMessage = 'Cannot be null') { + if (val == null) { + throw new NshmpError(`NullPointerException: ${errorMessage}`); + } + } + + /** + * Check whether a value is undefined. + * + * @param {Object} val The value to test + * @param {String=} errorMessage Optional error message + */ + static checkNotUndefined(val, errorMessage = 'Cannot be undefined') { + if (val == undefined) { + throw new NshmpError(`NullPointerException: ${errorMessage}`); + } + } + + /** + * Ensures the truth of an expression. + * + * @param {Boolean} expression Expression to check + * @param {String} errorMessage The exception message to use if the + * expression fails + */ + static checkState(expression, errorMessage) { + if (!expression) { + throw new NshmpError(`IllegalStateException: ${errorMessage}`); + } + } + + /** + * Check whether a value is an array. + * + * @param {Array} arr The array to test + * @param {String=} errorMessage An optional error message to show + */ + static checkStateArray(arr, errorMessage = 'Must be an array') { + Preconditions.checkState(Array.isArray(arr), errorMessage); + } + + /** + * Check whether an argument is an array and all elements + * inside are of specific type. + * + * @param {Array} arr Array to check + * @param {Object} type Type inside array to check + */ + static checkStateArrayInstanceOf(arr, type) { + Preconditions.checkArgumentArray(arr); + + for (let val of arr) { + Preconditions.checkStateInstanceOf(val, type); + } + } + + /** + * Check whether an array is of certain length. + * + * @param {Array} arr The array to test + * @param {Number} length The length the array should be + */ + static checkStateArrayLength(arr, length) { + Preconditions.checkArgumentArray(arr); + Preconditions.checkArgumentInteger(length); + Preconditions.checkState(arr.length == length); + } + + /** + * Check whether a value is an array and all elements inside the + * array are of a specificed type. + * + * @param {Array} arr The array to test + * @param {String} type The type of data inside the array + * @param {String=} errorMessage An optional error message to show + */ + static checkStateArrayOf(arr, type, errorMessage = 'Must be an array') { + Preconditions.checkArgumentArray(arr, errorMessage); + + for (let data of arr) { + Preconditions.checkStateTypeOf(data, type); + } + } + + /** + * Check whether a value is a boolean. + * + * @param {Boolean} val The value to test + */ + static checkStateBoolean(val) { + Preconditions.checkStateTypeOf(val, 'boolean'); + } + + /** + * Check whether a value is a integer. + * + * @param {Number} val The value to test + */ + static checkStateInteger(val) { + Preconditions.checkState(Number.isInteger(val), 'Must be an integer'); + } + + /** + * Check whether a value is a certain instance of a type. + * + * @param {Object} val The value to check + * @param {Object} type The type of instance the value should be + */ + static checkStateInstanceOf(val, type) { + Preconditions.checkState( + val instanceof type, + `Must be instance of [${type.name}]`); + } + + /** + * Check whether an argument is a HTMLElement + * + * @param {Object} val The value to check + * @param {Object} type The type of instance the value should be + */ + static checkStateInstanceOfHTMLElement(val) { + Preconditions.checkStateInstanceOf(val, HTMLElement); + } + + /** + * Check whether an argument is a Map + * + * @param {Object} val The value to check + * @param {Object} type The type of instance the value should be + */ + static checkStateInstanceOfMap(val) { + Preconditions.checkStateInstanceOf(val, Map); + } + + /** + * Check whether an argument is a Set + * + * @param {Object} val The value to check + * @param {Object} type The type of instance the value should be + */ + static checkStateInstanceOfSet(val) { + Preconditions.checkStateInstanceOf(val, Set); + } + + /** + * Check whether an argument is a SVGElement + * + * @param {Object} val The value to check + * @param {Object} type The type of instance the value should be + */ + static checkStateInstanceOfSVGElement(val) { + Preconditions.checkStateInstanceOf(val, SVGElement); + } + + /** + * Check whether a value is a number. + * + * @param {Number} val The value to test + */ + static checkStateNumber(val) { + Preconditions.checkStateTypeOf(val, 'number'); + } + + /** + * Check whether a value is an object. + * + * @param {Object} val The value to test + */ + static checkStateObject(val) { + Preconditions.checkStateTypeOf(val, 'object'); + } + + /** + * Check whether a property exists in an object. + * + * @param {Object} obj The object to check + * @param {String} property The property to see if exists + */ + static checkStateObjectProperty(obj, property) { + Preconditions.checkArgumentObject(obj); + Preconditions.checkArgumentString(property); + + Preconditions.checkState( + obj.hasOwnProperty(property), + `Must have property [${property}] in object`); + } + + /** + * Check whether a value is a string. + * + * @param {String} val The string to test + */ + static checkStateString(val) { + Preconditions.checkStateTypeOf(val, 'string'); + } + + /** + * Test whether a value is of a specific type. + * + * @param {Object} val The value to test + * @param {String} type The type the value should be + */ + static checkStateTypeOf(val, type) { + Preconditions.checkState(typeof(val) == type, `Must be of type [${type}]`); + } + +} diff --git a/webapp/apps/js/lib/Config.js b/webapp/apps/js/lib/Config.js new file mode 100644 index 000000000..3dee39453 --- /dev/null +++ b/webapp/apps/js/lib/Config.js @@ -0,0 +1,51 @@ +'use strict'; + +import Tools from './Tools.js'; +import NshmpError from '../error/NshmpError.js'; + +/** +* @fileoverview Static method to read two possible config files: +* 1. /nshmp-haz-ws/apps/js/lib/config.json +* 2. /nshmp-haz-ws/config.json +* The first config file, /nshmp-haz-ws/apps/config.json, is +* the default config file and remains in the repository. +* The second config file, /nshmp-haz-ws/config.js, is ignored by github and +* is for developmental purposes. If this file exists it will be read in +* and merged with the default, overriding any keys present in the first +* config file. +* The second file will be ignored if it is not present. +* +* @class Config +* @author bclayton@usgs.gov (Brandon Clayton) +*/ +export default class Config { + + /** + * @param {Class} callback - The callback must be a class as + * new callback(config) will be called. + */ + static getConfig(callback) { + let mainConfigUrl = '/nshmp-haz-ws/apps/config.json'; + let overrideConfigUrl = '/nshmp-haz-ws/config.json'; + + let jsonCall = Tools.getJSONs([mainConfigUrl, overrideConfigUrl]); + + Promise.all(jsonCall.promises).then((responses) => { + let mainConfig = responses[0]; + let overrideConfig = responses[1]; + + let config = $.extend({}, mainConfig, overrideConfig); + new callback(config); + }).catch((errorMessage) => { + if (errorMessage != 'Could not reach: /nshmp-haz-ws/config.json') return; + console.clear(); + jsonCall.promises[0].then((config) => { + new callback(config); + }).catch((errorMessage) => { + if (errorMessage != 'Could not reach: /nshmp-haz-ws/apps/config.json') return; + NshmpError.throwError(errorMessage); + }); + }); + } + +} diff --git a/webapp/apps/js/lib/Constraints.js b/webapp/apps/js/lib/Constraints.js new file mode 100644 index 000000000..f4320a0b3 --- /dev/null +++ b/webapp/apps/js/lib/Constraints.js @@ -0,0 +1,78 @@ +'use strict'; + +/** +* @fileoverview Class of static methods to listen and check if a input +* value is within certain bounds. +* If the value is outside the specified bounds a red focus ring is +* added to the parent element. +* +* @class Constraints +* @author bclayton@usgs.gov (Brandon Clayton) +*/ +export default class Constraints { + + /** + * @method check + * + * Check to see if the value of an element is within specified values + * and add a red focus ring to the parent of the element if the + * value is outside of the bounds. + * @param {!HTMLElement} el - DOM element of the input field + * @param {!number} minVal - Minimum value of bound (inclusive) + * @param {!number} maxVal - Maximum value of bound (inclusive) + * @param {boolean=} canHaveNaN - Whether the value can be empty + * @return {boolean} Whether the value is inside the bounds (true) or not + */ + static check(el, minVal, maxVal, canHaveNaN = false) { + let isInBounds; + let val = parseFloat(el.value); + + if (val < minVal || val > maxVal || (isNaN(val) && !canHaveNaN)) { + isInBounds = false; + } else { + isInBounds = true; + } + + d3.select(el.parentNode) + .classed('has-error', !isInBounds); + + return isInBounds; + } + + /** + * @method onInput + * + * Add listener, oninput, to a DOM element and check if the inputted + * value is within bounds. + * @param {!HTMLElement} el - DOM element of the input field + * @param {!number} minVal - Minimum value of bound (inclusive) + * @param {!number} maxVal - Maximum value of bound (inclusive) + * @param {boolean=} canHaveNaN - Whether the value can be empty + */ + static onInput(el, minVal, maxVal, canHaveNaN = false){ + $(el).on('input', (event) => { + Constraints.check(event.target, minVal, maxVal, canHaveNaN); + }); + } + + /** + * @method addTooltip + * + * Add a Bootstrap tooltip to a DOM element showing the specified bounds. + * Example: [0, 5] + */ + static addTooltip(el, minVal, maxVal) { + d3.select(el) + .attr('data-toggle', 'tooltip'); + + let title = '[' + minVal + ', ' + maxVal +']'; + let options = { + container: 'body', + }; + + $(el).attr('title', title) + .attr('data-original-title', title) + .tooltip(options); + } + +} diff --git a/webapp/apps/js/lib/ControlPanel.js b/webapp/apps/js/lib/ControlPanel.js new file mode 100644 index 000000000..c4551cf10 --- /dev/null +++ b/webapp/apps/js/lib/ControlPanel.js @@ -0,0 +1,1082 @@ +'use strict'; + +import Tools from './Tools.js'; +import Constraints from './Constraints.js'; + +/** + * @fileOverview Dynamically create a new control panel for a + * nshmp web application. + * + * For the control panel to render correctly the following files + * must be present in the HTML file: + * - CSS: Bootstrap v3.3.6 + * - CSS: webapp/apps/css/template.css + * - JavaScript: jQuery v3.2.1 + * - JavaScript: Bootstrap 3.3.7 + * - JavaScript: D3 v4 + * + * Use the {@link FormGroupBuilder} to create form groups that can + * consist of a: + * - Input form + * - Slider + * - Button group + * - Select menu + * + * @example + * // Create an empty control panel + * let controlPanel = new ControlPanel(); + * + * // Input form options + * let inputOptions = { + * id: 'zTop', + * label: 'zTop', + * labelColSize: 'col-xs-2', + * max: 700, + * min: 0, + * name: 'zTop', + * step: 0.5, + * value: 0.5, + * }; + * + * // Button group options + * let btnOptions = { + * addLabel: false, + * id: 'zTop-btn-group', + * name: 'zTop', + * }; + * + * // Slider options + * let sliderOptions = { id: 'zTop-slider' }; + * + * // zTop buttons for button group + * let zTopBtns = [ + * { text: '5.0 km', value: 5.0 }, + * { text: '10.0 km', value: 10.0 }, + * { text: '15.0 km', value: 15.0 }, + * ]; + * + * // Create a form group with a input, slider, and button group in + * // that order. + * let els = controlPanel.formGroupBuilder() + * .addInput(inputOptions) + * .addInputSlider(sliderOptions) + * .addBtnGroup(zTopBtns, btnOptions) + * .syncValues() + * .addInputTooltip() + * .addInputAddon('km') + * .build(); + * + * // Get input el + * let zTopEl = els.inputEl; + * + * // Get input group el + * let zTopInputGroupEl = els.inputGroupEl; + * + * // Get btn group el + * let zTopBtnGroupEl = els.btnGroupEl; + * + * // Get slider el + * let zTopSliderEl = els.sliderEl; + * + * @class ControlPanel + * @author Brandon Clayton + */ +export default class ControlPanel { + + constructor() { + let controlPanelD3 = d3.select('body') + .insert('div', '#content') + .attr('class', 'control-panel') + .attr('id', 'control'); + + let inputsD3 = controlPanelD3.append('form') + .attr('id', 'inputs'); + + let formHorizontalD3 = inputsD3.append('div') + .attr('class', 'form-horizontal'); + + /** The control panel element - @type {HTMLElement} */ + this.controlPanelEl = controlPanelD3.node(); + /** The inputs element - @type {HTMLElement} */ + this.inputsEl = inputsD3.node(); + /** The horizontal form element - @type {HTMLElement} */ + this.formHorizontalEl = formHorizontalD3.node(); + + /** + * Options for creating button groups. + * @see createBtnGroup + * + * + * @typedef {Object} BtnGroupOptions + * @property {Boolean} addLabel - To add a label before the button group. + * @property {HTMLElement} appendTo - Element to append the button group to. + * @property {String} btnGroupColSize - Bootstrap column size. + * @property {String} btnSize - The Bootstrap button size. + * @property {String} id - Button group id attibute. + * @property {String} label - The label text. + * @property {String} name - Button group name attribute. + * @property {String} paddingTop - The top padding for the button group. + * @property {String} title - Button group tittle attribute. + * @property {String} type - The type of button in the button group. + * 'radio' equals single selection, 'checkbox' equals multiple selection. + */ + this.btnGroupOptions = { + addLabel: false, + appendTo: this.formHorizontalEl, + btnGroupColSize: 'col-xs-10 col-xs-offset-2', + btnSize: 'btn-xs', + id: null, + label: null, + labelControl: true, + name: null, + paddingTop: '0.5em', + title: null, + type: 'radio', + }; + + /** + * Options for creating a Bootstrap form group div. + * @see createFormGroup + * + * @typedef {Object} FormGroupOptions + * @property {HTMLElement} appendTo - Where to append the form group div. + * @property {String} formClass - Any additional classes to add to the + * form group. + * @property {String} formSize - The Bootstrap form group size. + */ + this.formGroupOptions = { + appendTo: this.formHorizontalEl, + formClass: '', + formSize: 'form-group-sm', + }; + + /** + * Options for creating the ground motion model sorter. + * @see createGmmSorter + * + * @typedef {Object} GmmOptions + * @property {String} label - GMM sorter label. + * @property {String} labelFor - Where the label is bound. + * @property {Number} size - The size of the gmm select menu. + */ + this.gmmOptions = { + label: 'Ground Motion Models:', + labelControl: false, + labelFor: 'gmms', + size: 16, + }; + + /** + * Options for creating an input form. + * @see createInput + * + * @typedef {Object} InputOptions + * @property {Boolean} addLabel - To add a label before the input form. + * @property {HTMLElement} appendTo - Element to append the input form to. + * @property {Boolean} checked - Whether to be checked, only for + * ControlPanel#createCheckbox. + * @property {Boolean} disabled - Whether input form is disabled. + * @property {Boolean} formControl - Wheater to add the Bootstrap + * 'form-control' class to the the input form. + * @property {String} inputColSize - Bootstrap column size for the + * input form. + * @property {String} inputGroupFloat - Direction to float the + * input form. + * @property {String} label - The label if 'addLabel' is true. + * @property {String} labelColSize - The Bootstrap column size for the + * label if 'addLabel' is true. + * @property {Boolean} labelBreak - Add a line break after the label + * if true and 'addLabel' is true. + * @property {String} labelFloat - Direction to float the label. + * @property {Number} max - Sets the max attribute. + * @property {Number} min - Sets the min attribute. + * @property {String} name - Sets the name attribute. + * @property {Boolean} readOnly - Sets the readOnly property. + * @property {Number} step - Sets the step attribute. + * @property {String} text - The text for after a checkbox, only used + * for ControlPanel.createCheckbox. + * @property {String | Number} value - Sets the value attribute. + */ + this.inputOptions = { + addLabel: true, + appendTo: this.formHorizontalEl, + checked: false, + disabled: false, + formControl: true, + id: null, + inputColSize: 'col-xs-3', + inputGroupFloat: 'left', + inputGroupSize: 'input-group-sm', + label: '', + labelBreak: false, + labelColSize: '', + labelControl: true, + labelFloat: 'left', + max: null, + min: null, + name: null, + readOnly: false, + step: 1, + text: null, + type: 'number', + value: null, + }; + + /** + * Options for creating a label. + * @see createLabel + * + * @typedef {Object} LabelOptions + * @property {HTMLElement} appendTo - Where to append the form group div. + * @property {String} label - The label text. + * @property {String} labelColSize - The Bootstrap column size for the + * label if 'addLabel' is true. + * @property {String} labelFor - Where the label is bound. + * @property {String} float - Where the label should float. + */ + this.labelOptions = { + appendTo: this.formHorizontalEl, + label: '', + labelColSize: '', + labelControl: true, + labelFor: null, + float: 'initial', + }; + + /** + * Options for creating a select menu. + * @see createSelect + * + * @typedef {Object} SelectOptions + * @property {Boolean} addLabel - To add a label before the select menu. + * @property {HTMLElement} appendTo - Element to append the select menu to. + * @property {String} id - The id attribute of the select menu. + * @property {String} label - The label if 'addLabel' is true. + * @property {String} labelColSize - The Bootstrap column size for the + * label if 'addLabel' is true. + * @property {Boolean} labelBreak - Add a line break after the label + * if true and 'addLabel' is true. + * @property {Boolean} multiple - Whether the select is a multi select. + * @property {String} name - The name attribue of select menu. + * @property {Number} size - The size of the select menu. + * @property {String | Number} value - A selected value. + */ + this.selectOptions = { + addLabel: true, + appendTo: this.formHorizontalEl, + id: null, + label: '', + labelBreak: true, + labelColSize: '', + labelControl: true, + multiple: false, + name: null, + size: 0, + value: '', + }; + + /** + * Options for creating a slider. + * @see createInputSlider + * + * @typedef {Object} SliderOptions + * @property {HTMLElement} appendTo - Where to append the form group div. + * @property {String} id - The id of the slider. + * @property {HTMLElement} inputEl - The input form element that the slider + * should be bound to. + * @property {String} sliderColSize - The Bootstrap column size for the + * slider. + */ + this.sliderOptions = { + appendTo: this.formHorizontalEl, + id: '', + inputEl: null, + sliderColSize: 'col-xs-5 col-xs-offset-1', + } + } + + /** + * Return a new {@link FormGroupBuilder} to build a form + * group that can consists of a: + * - input form with Bootstrap input add on + * - slider bound to an input form + * - checkbox + * - select menu + * - button group + * + * @example + * // Create empty control panel + * let controlPanel = new ControlPanel(); + * + * // Get form group builder + * let formBuilder = controlPanel.formGroupBuilder(); + * + * @param {FormGroupOptions} formGroupOptions The form group options. + * @returns A new instance of FormGroupBuilder + */ + formGroupBuilder(formGroupOptions) { + return new this.FormGroupBuilder(formGroupOptions); + } + + /** + * Build a Bootstrap form group with combinations of: + * - input form with Bootstrap input add on + * - slider bound to an input form + * - checkbox + * - select menu + * - button group + * + * NOTE: The current builder cannot add multiple of the same element. + */ + get FormGroupBuilder() { + let controlPanel = this; + + class FormGroupBuilder { + + /** + * Create the form group needed to add element to. + * @param {FormGroupOptions} formGroupOptions + */ + constructor(formGroupOptions) { + /** The form group element - @type {HTMLElement} */ + this.formGroupEl = controlPanel._createFormGroup(formGroupOptions); + + /** Button group element - @type {HTMLElement} */ + this.btnGroupEl = undefined; + /** Checkbox element - @type {HTMLElement} */ + this.checkboxEl = undefined; + /** Input form element - @type {HTMLElement} */ + this.inputEl = undefined; + /** Input group element - @type {HTMLElement} */ + this.inputGroupEl = undefined; + /** Select ment element - @type {HTMLElement} */ + this.selectEl = undefined; + /** Slider element - @type {HTMLElement} */ + this.sliderEl = undefined; + + /** + * Whether to sync the values between an input form, slider, and button + * group. + * + * Syncing the values will work as long as any two of these + * elements exists. + * + * This is set to true by using the {@link syncValues} method. + * @type {Boolean} + */ + this._toSyncValues = false; + + /** + * Whether to add a Bootstrap tooltip to the input form. + * + * This is set to true by using the {@link addInputTooltip} method. + * + * NOTE: The input form must have the min and max attribute set + * for this to work. + * @see Constraints#addTooltip + * @type {Boolean} + */ + this._toAddInputTooltip = false; + + /** + * Whether to add a bootstrap input form add on at the end of the + * input form. + * + * This is set to true by using the {@link addInputAddon} method/ + * @type {Boolean} + */ + this._toAddInputAddon = false; + } + + /** + * Build the form group and return an Object with + * the HTMLElements based on what was choosen to be built. + * + * @return {FormGroupElements} All HTMLElement + */ + build() { + /** + * The {HTMLElement}s associated with the elements created. + * @typedef {Object} FormGroupElements + * @property {HTMLElement} formGroupEl - The form group element. + * @property {HTMLElement} btnGroupEl - The button group element. Only + * returned if addBtnGroup is called in the builder. + * @property {HTMLElement} checkboxEl - The checkbox element. Only + * returned if addCheckbox is called in the builder. + * @property {HTMLElement} inputEl - The input element. Only returned + * if addInput is called in the builder. + * @property {HTMLElement} inputGroupEl - The input group element + * where the input form exist. Only returned if addInput + * is called in the builder. + * @property {HTMLElement} sliderEl - The slider element. Only + * returned if addInputSlider is called in the builder. + * @property {HTMLElement} selectEl - The select menu element. Only + * retunred if the addSelect is called in the builder. + */ + let els = {}; + + els.formGroupEl = this.formGroupEl; + + if (this.btnGroupEl) { + els.btnGroupEl = this.btnGroupEl; + } + + if (this.checkboxEl) { + els.checkboxEl = this.checkboxEl; + } + + if (this.inputEl) { + els.inputEl = this.inputEl; + els.inputGroupEl = this.inputGroupEl; + + if (this._toAddInputAddon) { + controlPanel._inputAddon(this.inputEl, this.addonText); + } + + if (this._toAddInputTooltip) { + Constraints.addTooltip( + this.inputEl, + this.inputEl.min, + this.inputEl.max) + } + } + + if (this.sliderEl) { + els.sliderEl = this.sliderEl; + } + + if (this.selectEl) { + els.selectEl = this.selectEl; + } + + if (this._toSyncValues) { + controlPanel._syncValues( + this.inputEl, + this.btnGroupEl, + this.sliderEl); + } + + return els; + } + + /** + * Add a button group to the form group. + * @param {Buttons} btns The buttons for the button group. + * @param {BtnGroupOptions} options The button group options. + */ + addBtnGroup(btns, options) { + options.appendTo = this.formGroupEl; + this.btnGroupEl = controlPanel._createBtnGroup(btns, options); + return this; + } + + /** + * Add a checkbox to the form group. + * @param {InputOptions} options The input options. + */ + addCheckbox(options) { + options.appendTo = this.formGroupEl; + this.checkboxEl = controlPanel._createCheckbox(options); + return this; + } + + /** + * Add an input form to the form group. + * @param {InputOptions} options The input options. + */ + addInput(options) { + options.appendTo = this.formGroupEl; + this.inputEl = controlPanel._createInput(options); + this.inputGroupEl = this.inputEl.parentNode; + return this; + } + + /** + * Add the input form addon . + * @param {String} text The text to add to the input addon + */ + addInputAddon(text) { + this.addonText = text; + this._toAddInputAddon = true; + return this; + } + + /** + * Add a slider to the form group. + * @param {SliderOptions} options The slider options. + */ + addInputSlider(options) { + options.appendTo = this.formGroupEl; + options.inputEl = this.inputEl; + this.sliderEl = controlPanel._createInputSlider(options); + return this; + } + + /** + * Add a Boostrap tooltip to the input form. + */ + addInputTooltip() { + this._toAddInputTooltip = true; + return this; + } + + /** + * Add a select menu to the form group. + * @param {Array} optionArray The select menu values. + * @param {SelectOptions} options The select menu options. + */ + addSelect(optionArray, options) { + options.appendTo = this.formGroupEl; + this.selectEl = controlPanel._createSelect(optionArray, options); + return this; + } + + /** + * Sync the values between a input form, slider, and a button group. + * A minimum of two of these elements must be defined. + */ + syncValues() { + this._toSyncValues = true; + return this; + } + + } + return FormGroupBuilder; + } + + createGmmSelect(spectraParameters, gmmOptions = {}) { + let options = $.extend({}, this.gmmOptions, gmmOptions); + + let gmmSorterEls = this._createGmmSorter(options); + let gmmSorterEl = gmmSorterEls.gmmSorterEl; + let gmmFormGroupEl = gmmSorterEls.formGroupEl; + + /* Alphabetic GMMs */ + let gmmAlphaOptions = $(); + spectraParameters.gmm.values.forEach((gmm) => { + gmmAlphaOptions = gmmAlphaOptions.add($('') + .attr('label', group.label) + .attr('id', group.id); + gmmGroupOptions = gmmGroupOptions.add(optGroup); + optGroup.append(gmmAlphaOptions + .filter((index, gmmOption) => { + return members.includes(gmmOption.getAttribute('value')); + }) + .clone()); + }); + + let gmmsEl = this._createSelect( + gmmGroupOptions, { + appendTo: gmmFormGroupEl, + addLabel: false, + id: 'gmms', + name: 'gmm', + multiple: true, + size: options.size}); + + /* Bind option views to sort buttons */ + $('input', gmmSorterEl).change((event) => { + let options = event.target.value === 'alpha' ? + gmmAlphaOptions : gmmGroupOptions; + this.updateSelectOptions(gmmsEl, options); + $(gmmsEl).scrollTop(0); + Tools.resetRadioButton(event.target); + }); + + /* Add tooltips */ + $(gmmSorterEls.gmmAlphaEl).tooltip({container: 'body'}); + $(gmmSorterEls.gmmGroupEl).tooltip({container: 'body'}); + + let els = { + gmmSorterEl: gmmSorterEl, + gmmAlphaEl: gmmSorterEls.gmmAlphaEl, + gmmGroupEl: gmmSorterEls.gmmGroupEl, + gmmFormGroupEl: gmmFormGroupEl, + gmmAlphaOptions: gmmAlphaOptions, + gmmGroupOptions: gmmGroupOptions, + gmmsEl: gmmsEl, + }; + + return els; + } + + /** + * Create a label. + * @param {LabelOptions} labelOptions + */ + createLabel(labelOptions) { + let options = $.extend({}, this.labelOptions, labelOptions); + + let labelD3 = d3.select(options.appendTo) + .append('label') + .attr('class', options.labelColSize) + .classed('control-label', options.labelControl) + .attr('for', options.labelFor) + .style('float', options.float) + .html(options.label); + + return labelD3.node(); + } + + /** + * Convert a single selectable button group (a radio button group) + * to a multi-selectable button group (a checkbox button group) and + * disable the input form and slider (if present). + * + * The name attribute is removed and kept under the data-name attribute + * so the input form will not be included in a serialization of + * the inputs; + * + * @param {FormGroupElements} els The elements of the input form, + * button group, and slider (optional). + * - els.inputEl + * - els.btnGroupEl + * - els.sliderEl + */ + toMultiSelectable(els) { + let inputName = els.inputEl.getAttribute('name'); + d3.select(els.inputEl) + .property('disabled', true) + .attr('name', ''); + + if (els.sliderEl) els.sliderEl.disabled = true; + + d3.select(els.btnGroupEl) + .selectAll('input') + .attr('type', 'checkbox') + .each((d, i, el) => { + let isActive = d3.select(el[i].parentNode) + .classed('active'); + + if (isActive) el[i].checked = true; + }); + } + + /** + * Convert an array of objects into an array of option elements + * for a select menu. + * + * The object inside the array must have a text and value field. + * + * @param {Array} values The values to convert. + * @return {Array} The array of option elements. + */ + toSelectOptionArray(values) { + let optionArray = []; + values.forEach((val) => { + let el = d3.create('option') + .attr('value', val.value) + .text(val.text) + .node(); + optionArray.push(el); + }); + return optionArray; + } + + /** + * Convert a multi-selectable button group (a checkbox button group) + * to a single selectable button group (a radio button group) and + * re-enable the input form and slider (if present). + * + * The name attribute is re-added to the input form so it can + * be included in any serialization of the inputs. + * + * @param {FormGroupElements} els The elements of the input form, + * button group, and slider (optional). + * - els.inputEl + * - els.btnGroupEl + * - els.sliderEl + */ + toSingleSelectable(els) { + let inputName = els.inputEl.dataset.name; + d3.select(els.inputEl) + .property('disabled', false) + .attr('name', inputName); + + if (els.sliderEl) els.sliderEl.disabled = false; + + d3.select(els.btnGroupEl) + .selectAll('label') + .classed('active', false) + .selectAll('input') + .attr('type', 'radio') + .property('checked', false); + + $(els.inputEl).trigger('input'); + } + + /** + * Update a select menu values. + * + * @param {HTMLElement} selectEl The select menu element. + * @param {Array} optionArray The select menu option array. + */ + updateSelectOptions(selectEl, optionArray) { + d3.select(selectEl) + .selectAll('*') + .remove(); + + d3.select(selectEl) + .selectAll('*') + .data($(optionArray).clone()) + .enter() + .append((d) => { return d; }); + } + + /** + * @private + * Create a Bootstrap radio/checkbox button group in the control + * panel. + * + * The type of button group is defined by the 'type' option. + * + * @param {Buttons} btns The buttons that make up the button group. + * @param {BtnGroupOptions} btnGroupOptions The button group options. + * @returns {HTMLElement} The element representing the button group. + */ + _createBtnGroup(btns, btnGroupOptions) { + /** @type {BtnGroupOptions} */ + let options = $.extend({}, this.btnGroupOptions, btnGroupOptions); + + if (options.addLabel) { + this.createLabel({ + appendTo: options.appendTo, + label: options.label, + labelControl: options.labelControl, + labelFor: options.id}); + } + + let btnGroupD3 = d3.select(options.appendTo) + .append('div') + .attr('class', 'btn-group') + .classed(options.btnGroupColSize, true) + .attr('data-toggle', 'buttons') + .attr('id', options.id) + .attr('name', options.name) + .style('padding-top', options.paddingTop); + + btnGroupD3.selectAll('label') + .data(btns) + .enter() + .append('label') + .attr('class', 'btn btn-default') + .classed('active', (d) => { return d.isActive; }) + .classed(options.btnSize, true) + .html((d) => { + return ' ' + d.text; + }); + + return btnGroupD3.node(); + } + + /** + * @private + * Create a checkbox in the control panel. + * + * @param {InputOptions} inputOptions The input options for the checkbox. + * @returns {HTMLElement} The checkbox element. + */ + _createCheckbox(inputOptions) { + let options = $.extend({}, this.inputOptions, inputOptions); + options.type = 'checkbox'; + options.formControl = false; + options.addLabel = false; + + let tmpEl = this._createInput(options); + let inputD3 = d3.select(tmpEl.parentNode) + .append('label') + .attr('class', 'control-label secondary-input') + .attr('for', options.id) + .html(tmpEl.outerHTML + ' ' + options.text) + .select('input'); + + d3.select(tmpEl).remove(); + + inputD3.property('checked', options.checked); + return inputD3.node(); + } + + /** + * @private + * Create a form group in the control panel. + * + * @param {FormGroupOptions} formGroupOptions The form group options. + * @returns {HTMLElement} The form group element. + */ + _createFormGroup(formGroupOptions) { + let options = $.extend({}, this.formGroupOptions, formGroupOptions); + + let formGroupD3 = d3.select(options.appendTo) + .append('div') + .attr('class', 'form-group') + .classed(options.formClass, true) + .classed(options.formSize, true) + .attr('id', options.id); + + return formGroupD3.node(); + } + + /** + * @private + * Convience method to creating the ground motion model sorter. + * @param {GmmOptions} options The GMM options. + */ + _createGmmSorter(options) { + let formGroupEl = this._createFormGroup(); + + this.createLabel({ + appendTo: formGroupEl, + label: options.label, + labelControl: options.labelControl, + labelFor: options.labelFor}); + + let gmmSorterD3 = d3.select(formGroupEl) + .append('div') + .attr('id', 'gmm-sorter') + .style('float', 'right') + .attr('class', 'btn-group btn-group-xs') + .attr('data-toggle', 'buttons'); + + gmmSorterD3.append('label') + .attr('class', 'btn btn-default gmm-group active') + .attr('data-toggle', 'tooltip') + .attr('data-container', 'body') + .attr('title', 'Sort by Group') + .attr('for', 'gmm-sort-group') + .html(' ' + + '