diff --git a/docs/PLS/MLB-Park-Factor/0-MLBParkFactor-Goals.md b/docs/PLS/MLB-Park-Factor/0-MLBParkFactor-Goals.md new file mode 100644 index 0000000..54377d4 --- /dev/null +++ b/docs/PLS/MLB-Park-Factor/0-MLBParkFactor-Goals.md @@ -0,0 +1,26 @@ +--- +title: MLB Park Factor PLS Goals +summary: Goals and outline of the MLB Park Factor Project Lab Series +authors: + - Composable Analytics, Inc. +date: 2026-08-19 +some_url: https://docs.composable.ai +--- + +In the **MLB Park Factor** entry in the **Project Lab Series** we will utilize four building blocks of the Composable platform: DataFlows, DataPortals, QueryViews, and WebApps. + +The project answers one question: does a ballpark play differently at night than it does during the day? For every park, every calendar month, and both times of day, we compute a **park factor**, the park's average runs per game divided by the league's average runs for that same month and time of day. A park factor of 1.15 means 15% more scoring than the league average, and 0.85 means 15% less. + +We will use **[DataFlows](../../DataFlows/01.Overview.md)** to pull eleven seasons of schedule data from the public MLB Stats API, clean it into tabular format, load it, and serve it back out over HTTP as JSON. + +The **[DataPortal](../../DataPortals/01.Overview.md)** will let us create a database from an Excel model file, then store one record per game played. + +With the **[QueryView](../../QueryViews/01.Overview.md)**, we will compute park factors in SQL and review the stored data as an interactive grid. + +Finally, a **[WebApp](../../WebApps/01.Overview.md)** will chart the results, so that picking a ballpark draws its day and night park factor month by month. + +![!The finished WebApp](img/PFWebAppResult.png) + +Each tutorial builds on the one before it, and each produces something you can run on its own. + +Let's get started! diff --git a/docs/PLS/MLB-Park-Factor/1-DataFlows.md b/docs/PLS/MLB-Park-Factor/1-DataFlows.md new file mode 100644 index 0000000..6e96288 --- /dev/null +++ b/docs/PLS/MLB-Park-Factor/1-DataFlows.md @@ -0,0 +1,238 @@ +--- +title: Composable Docs +summary: Building the three DataFlows that pull MLB schedule data, load it into a DataPortal, and serve park factors as JSON +authors: + - Composable Analytics, Inc. +date: 2026-08-19 +some_url: https://docs.composable.ai + +--- + +# Reading Ballpark Data from the MLB Stats API + +Every analysis starts by collecting data. In this tutorial we build three [DataFlows](../../DataFlows/01.Overview.md): the first extracts and cleans a single team's games, the second runs it for the whole league and stores the results in a DataPortal, and the third serves those results back out over HTTP as JSON. + +The dataset is the [MLB Stats API](https://statsapi.mlb.com) schedule endpoint, which is public and needs no API key. A single request returns every game a team played in a season, including the venue, the final score, and whether the game was played in the day or at night. + +The DataFlows in the second and third parts of this tutorial address the DataPortal we create in the [Creating a DataPortal](2-DataPortal.md) tutorial, so they need its ID. Either jump ahead and create the portal first, since it is a five minute step, or build these DataFlows now and fill in the ID afterwards. + +## Extracting One Team's Games + +This DataFlow does the real work of talking to the API. It takes a team ID, goes through the past eleven seasons, and returns one clean table of every regular season game that team played, each row labelled with the park the game was played in. + +Create a DataFlow named something like `mlballpark_TOD`, (you can add a description if you like). + +### Looping Through the Seasons + +Start with an `External String Input` module with name set to `TeamID`. For now, we can let the input be `112` as a placeholder for testing. Declaring it as an *external* input is what lets the next DataFlow call this one as a module and pass a different team each time. + +Add an `Array Builder` module named `Seasons`. Set `OutputType` to `String` and list the seasons to cover. + +``` +2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026 +``` + +Add a `ForEach Map` module and connect `Seasons.ArrayOutput` to its `List` input. Everything downstream of it runs once per season. + +Now build the request url with a `String Formatter` module. Connect `External String Input` to its `Parameters` input first, then `ForEach.Object`. The format string reads them positionally, so the order of these two connections is the difference between a working url and a 404. Enter the following as the `Format`. + +``` +https://statsapi.mlb.com/api/v1/schedule?sportId=1&teamId={0}&startDate={1}-04-01&endDate={1}-10-31&hydrate=team,linescore,venue +``` + +Connect `String Formatter.Result` to the `Uri` input of a `WebClient` module, and set the `Method` to `GET`. + +![!Season loop feeding the WebClient](img/PFSeasonLoop.png) + +Reading the connection dots left to right, `Seasons` feeds the `List` input of `ForEach`, and both `ForEach.Object` and `External String Input` feed `String Formatter.Parameters`. The formatted url then feeds `WebClient.Uri`. + +### Parsing and Cleaning the Response + +The response is JSON, so the `JSON To Table` module turns it into a Composable Table. Connect `WebClient.Result` to its `InputJson` input, and enter the following eight expressions as the `JSONPaths`. The filter in each path drops any game that never reached a final score like a rainout. + +``` +$.dates[*].games[?(@.status.detailedState=='Final')].officialDate +$.dates[*].games[?(@.status.detailedState=='Final')].dayNight +$.dates[*].games[?(@.status.detailedState=='Final')].gameType +$.dates[*].games[?(@.status.detailedState=='Final')].status.detailedState +$.dates[*].games[?(@.status.detailedState=='Final')].teams.home.team.venue.name +$.dates[*].games[?(@.status.detailedState=='Final')].teams.home.score +$.dates[*].games[?(@.status.detailedState=='Final')].teams.away.score +$.dates[*].games[?(@.status.detailedState=='Final')].season +``` + +Enter the matching `ColumnNames`, in the same order. + +``` +officialDate, dayNight, gameType, status, venue, homeScore, awayScore, season +``` + +Next, filter to regular season games only, since spring training and postseason games are played under different conditions and would pollute the averages. Add a `Table Filter` module, then right click on its `Clause` input and Composable will suggest the `Table Filter Operator Clause` module. Set `ColumnName` to `gameType`, `Operator` to `=`, and `Value` to `R`. Connect `JSON To Table.Table` to the `Table` input. + +Finally, add a `Table Query` module fed from `Table Filter.Result`. This is the cleaning step, deriving the month bucket and giving the venue the name the rest of the pipeline uses. + +```sql +SELECT *, substr(OfficialDate, 1, 7) AS Month, Venue AS Park +FROM [t0] +``` + +![!Parsing and filtering the schedule response](img/PFParseAndClean.png) + +The Designer truncates long input values, so the boxes read `["$.dates[*].ga`, `["officialDate"` and `SELECT *,`. The full values are the code blocks above. Note that `Table Filter` takes two connections: the table arrives at `Table`, and the clause at `Clause`. + +### Combining the Seasons into One Table + +Add an `Accumulator` module, connecting `Table Query.Result` to `Input` and `ForEach.LoopComplete` to `Trigger`. The accumulator collects one table per season and releases them together when the loop finishes. + +Then add a `Table Set Operation` module with the operation set to `Union All`, fed from `Accumulator.Result`. Eleven season tables become one. + +Finish with an `External Table Output` module fed from `Table Set Operation.Result`. This is the port the next DataFlow reads, allowing this DataFlow to be used [from another DataFlow](../../DataFlows/06.DataFlow-Reuse.md). + +![!Accumulating and unioning the season tables](img/PFUnionOutput.png) + +Save, then run the DataFlow once. It should finish in a couple of seconds and produce a few hundred rows. Open the `External Table Output` result and confirm you have `Park`, `Month`, `dayNight`, `homeScore` and `awayScore` columns holding sensible values before moving on. + +## Running the League and Loading the DataPortal + +The second DataFlow fans the first one out across the league and lands the results in the DataPortal. + +Create a DataFlow named something like `mlballparksync`, (again you can add a description if you'd like). + +### Looping Through the Teams + +Now we need to extract the teamIDs for each of the 30 teams in the MLB. + +Add a `WebClient` module with the `Method` set to `GET`, and the following `Uri`. + +``` +https://statsapi.mlb.com/api/v1/teams?sportId=1 +``` + +Add a `JSON To Table` module fed from `WebClient.Result`, with a single `JSONPath`. + +``` +$.teams[*].id +``` + +And a single `ColumnName`. + +``` +TeamID +``` + +Add a `Table ForEach Map` module wired from that table, which loops once per team, and a `TableRow Cell Selector` module with `ColNameOrIndex` set to `TeamID`, fed from `Table ForEach.TableRow`. + +![!The team list, looped one row at a time](img/PFTeamList.png) + +Here the `JSON To Table` module shows its single path and column name truncated as `["$.teams[*].ic` and `["TeamID"]`. The `TableRow Cell Selector` reads `"TeamID"` as well, because it pulls that column out of each row. + +### Nesting the First DataFlow + +In the module sidebar, go to `My DataFlows` or `Search All DataFlows` and enter `mlballpark_TOD` (or whatever you chose to name your first dataflow), then drag it onto the canvas. Composable adds it as an `App Reference Module` whose ports are the external inputs and outputs we declared in the first DataFlow. Connect `TableRow Cell Selector.CellValue` to its `TeamID` input. + +Add an `Accumulator` module fed from the nested DataFlow's `External Table Output` and triggered by `Table ForEach.LoopComplete`, followed by a `Table Set Operation` module set to `Union All`. This is the same pattern as before: every team's table becomes one league wide table. + +### Inserting Data with the DataPortal Sync Module + +Add a `Table to Form Automapper` module, which the Designer labels `DataPortal Sync`, and connect `Table Set Operation.Result` to its `Data` input. The [DataPortalSync](../../DataFlows/09.Module-Details/DataPortalSync.md) module takes care of auditing changes to the database table as well as transforming category fields to an integer lookup. + +| Input | Value | +| ---------------- | --------------------------------------------------------------------- | +| FormId | The ID of the `MLBParkFactor` DataPortal | +| ContainerName | Games | +| ParentInstanceId | 1 (the single `MLBParkFactorHome` instance that owns the Games table) | +| DeleteUnmatched | All | + +![!The nested DataFlow feeding DataPortal Sync](img/PFSyncPortal.png) + +On the far left, `mlballpark_...` is the nested `mlballpark_TOD`, marked with a chain link icon and exposing a single `TeamID` port. On the far right, `DataPortal Sync` shows `FormId` as `MLBParkF...`, `ContainerName` as `Games`, `ParentInstanceId` as `1` and `DeleteUnmatched` as `All`. Leave `AppendInstanceIds`, `JoinColumns`, `JoinOnParent` and `IncludeChildInstanceIds` at their defaults. + +The module maps source columns onto container fields by name, so `Park`, `Month`, `season`, `homeScore`, `awayScore`, `officialDate` and `dayNight` each land in their matching field. The `gameType` and `status` columns have no matching field and are ignored, which is fine, since we already filtered on them. + +Save and run this DataFlow. It takes a few minutes, because it makes one API request per team per season. When the run finishes, open the `Counts` output to see what the sync did. + +``` +Records Inserted 48494 +Records Updated 0 +Records Deleted 647 +Total Records Processed 49141 +``` + +Check the `Errors` output as well. It should be an empty list, and anything in it is almost always a field name mismatch back in the DataPortal model file. + +!!! note + `DeleteUnmatched: All` makes every run a full refresh. Because we set no `JoinColumns`, the module cannot match existing records, so it inserts the whole table fresh and deletes whatever was there before. That is what we want for a rebuild from source pipeline, and it is why the deleted count is non-zero on second and later runs. To update in place instead, set `JoinColumns` to a combination that uniquely identifies a game. + +## Serving the Results as JSON + +A WebApp cannot read a QueryView directly, so we publish the same result set from an HTTP activated DataFlow. This version queries the portal through Composable rather than through SQL, so it needs no database credentials at all. + +Create a DataFlow named something like `mlballpark_TOD_api`. + +Add a `Web Receive` module with the `Method` set to `GET`. Its presence is what makes the DataFlow reachable over HTTP. + +Add a [DataPortal Query](../../DataFlows/09.Module-Details/DataPortalQuery.md) module named `Games From Portal`. Set `DataPortalId` to the ID of the DataPortal and enter the query below. The module uses Entity SQL, in which container names are pluralized and aliased. + +```sql +SELECT g.Park, g.Month, g.DayNight, g.HomeScore, g.AwayScore FROM Games AS g +``` + +Add a `Table Query` module named `Park Factor`, fed from `Games From Portal.Results`. It performs the same aggregation as the [QueryView](3-QueryView.md) we build later in the series, in sqlite syntax this time, and names the columns exactly as the WebApp expects them. + +```sqlite +SELECT + p.Park, + p.CalendarMonth, + p.DayNight, + p.AvgRuns AS ParkAvgRuns, + l.AvgRuns AS LeagueAvgRuns, + p.AvgRuns / l.AvgRuns AS ParkFactor, + (p.AvgRuns / l.AvgRuns - 1) * 100 AS ParkFactorDeviationPct +FROM + (SELECT Park, substr(Month, 6, 2) AS CalendarMonth, DayNight, + AVG(CAST(HomeScore AS FLOAT) + CAST(AwayScore AS FLOAT)) AS AvgRuns + FROM [t0] + GROUP BY Park, substr(Month, 6, 2), DayNight) p +JOIN + (SELECT substr(Month, 6, 2) AS CalendarMonth, DayNight, + AVG(CAST(HomeScore AS FLOAT) + CAST(AwayScore AS FLOAT)) AS AvgRuns + FROM [t0] + GROUP BY substr(Month, 6, 2), DayNight) l +ON p.CalendarMonth = l.CalendarMonth AND p.DayNight = l.DayNight +ORDER BY p.Park, p.CalendarMonth, p.DayNight +``` + +Add a `Table to JSON` module named `Park Factor JSON`, fed from `Park Factor.Result`, with `Indexable` checked. Indexable output gives each row named properties, which is what the page reads. + +Finish with a `Web Send` module. Connect `Park Factor JSON.Json` to `ResponseIn`, set the `ContentType` to `application/json` and the `StatusCode` to `200`. + +![!The five modules of the API DataFlow](img/PFApiModules.png) + +`Web Receive` sits alone at the bottom left and wires into nothing, because we do not read anything out of the request. Its only job is to make the DataFlow reachable over HTTP. The data path runs along the top, from `Games From Po...` to `Park Factor` to `Park Factor JS...` to `Web Send`. + +Save and run the DataFlow once from the Designer. A manual run exercises every module except the HTTP plumbing, and the `Web Send` output shows exactly what a caller will receive. + +```json +{ + "Headers": ["Park", "CalendarMonth", "DayNight", "ParkAvgRuns", + "LeagueAvgRuns", "ParkFactor", "ParkFactorDeviationPct"], + "Rows": [ ... 593 rows ... ] +} +``` + +Now note the DataFlow's ID, which is the `appId` in the Designer's own address bar. + +![!The Designer url showing the appId](img/PFApiAppId.png) + +In `localhost/CompApp/Designer.aspx?appId=89113`, the number after `appId=` is the ID this DataFlow answers on. Its activation url follows the pattern below, and is what the [WebApp](4-WebApp.md) will call. + +``` +/services/WebActivationService.svc/Activate?appId= +``` + +!!! note + Anyone loading the WebApp needs Execute permission on this DataFlow, because their browser is what calls the url. Without it the request comes back `500` with *"User does not have Execute permissions for resource ..."*. Grant Execute to whichever group should see the page. + +## Next Steps + +We now have the data extracted, cleaned, and available both as a stored table and as JSON over HTTP. Next, we set up the database that the sync module writes into, with a [DataPortal](2-DataPortal.md). diff --git a/docs/PLS/MLB-Park-Factor/2-DataPortal.md b/docs/PLS/MLB-Park-Factor/2-DataPortal.md new file mode 100644 index 0000000..469a5a0 --- /dev/null +++ b/docs/PLS/MLB-Park-Factor/2-DataPortal.md @@ -0,0 +1,110 @@ +--- +title: Composable Docs +summary: Building the Excel model file for the park factor DataPortal and uploading it to create the database +authors: + - Composable Analytics, Inc. +date: 2026-08-19 +some_url: https://docs.composable.ai + +--- + +# Creating the Park Factor DataPortal + +In an ETL pipeline, the next step after processing data from an external source is to put it in a data store. A [DataPortal](../../DataPortals/01.Overview.md) makes setting up a database from the data model very simple. + +In this tutorial, we will continue from the [Reading Ballpark Data from the MLB Stats API](1-DataFlows.md) tutorial to use it as our dataset, and build the DataPortal that its `mlballparksync` DataFlow (or whatever you named your sync DataFlow) writes into. One record is stored per game played, so that the park factor calculation always runs against the same permanent store rather than against a fresh set of API calls. + +The field names we choose here are a contract. The `DataPortal Sync` module matches source columns to container fields by name, so a typo on this page silently drops a column in the DataFlow. + +## The DataPortal Model File + +A DataPortal's data model lives in an Excel workbook, with one sheet per container and one row per field. You build the workbook, upload it, and Composable creates the portal, its containers, its picklists, and the database behind them. + +The Excel file used in this tutorial is available here: Download MLB Park Factor DataPortal Model (xlsx) + +### Master Sheet + +In the [master sheet](../../DataPortals/03.MasterSheet.md), we name the database, and use the `Link` ControlType to point towards the entry page of the DataPortal. As `Type`, enter `Form.MLBParkFactorHome`, which points towards another sheet in the file. + +| [Name](../../DataPortals/06.Setting-Details/Name.md) | [DisplayName](../../DataPortals/06.Setting-Details/DisplayName.md) | [Type](../../DataPortals/06.Setting-Details/Type.md) | [ControlType](../../DataPortals/06.Setting-Details/ControlType.md) | +| ---------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------ | +| MLBParkFactor | MLB Day/Night Park Factor | Form.MLBParkFactorHome | [Link](../../DataPortals/05.Control-Details/Link.md) | + +![!DataPortal Master Sheet](img/PFPortalMaster.png) + +Row 1 is the header and row 2 is the portal itself. + +### Master.settings (optional step) + +[Settings pages](../../DataPortals/06.SettingSheet.md) are optional. The heading fields are `Option, Value`. Here, we disable the AutoSave feature. With AutoSave enabled, when you start entering data on a DataPortal page, it is automatically saved, even if your entry is not complete. When it is turned off, you need to click the `Save` Button for the data to be saved. + +| Option | Value | +| -------- | ----- | +| AutoSave | FALSE | + +### Games Container Page + +We're going to skip over the `MLBParkFactorHome` sheet that we linked from the master page, and instead first create the container where we will be storing the game data that we processed in the previous DataFlow. This is where we define the schema of the table, defining the names and datatypes. In our DataPortal, we also pick a ControlType for how to display these fields to a user entering in data. + +Now go through each of the fields in the dataset, and list out their properties. In the [`Name`](../../DataPortals/06.Setting-Details/Name.md) column, we want these to match our dataset exactly, since these are the names the `DataPortal Sync` module matches against. In the [`DisplayName`](../../DataPortals/06.Setting-Details/DisplayName.md) field, we make them more readable. In the [`Type`](../../DataPortals/06.Setting-Details/Type.md) column, we are mostly using C# System Types: strings for the park and month text, integers for the scores and the season, and datetime for the game date. For the DayNight field, we instead use a [`Category`](../../DataPortals/05.Control-Details/Category.md) control type to limit the input values to the two times of day. We define the category values in the `Categories` sheet of our excel file. + +| [Name](../../DataPortals/06.Setting-Details/Name.md) | [DisplayName](../../DataPortals/06.Setting-Details/DisplayName.md) | [Description](../../DataPortals/06.Setting-Details/Description.md) | [Type](../../DataPortals/06.Setting-Details/Type.md) | [ControlType](../../DataPortals/06.Setting-Details/ControlType.md) | +| ---------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------ | +| Park | Park | Ballpark name (e.g. Wrigley Field) | System.String | [Text](../../DataPortals/05.Control-Details/Text.md) | +| Season | Season | MLB season year | System.Int32 | [Spin](../../DataPortals/05.Control-Details/Spin.md) | +| Month | Month | Calendar month, YYYY-MM | System.String | Text | +| DayNight | Day/Night | Game time of day: day or night | Form.DayNight | [Category](../../DataPortals/05.Control-Details/Category.md) | +| HomeScore | Home Score | Runs scored by home team | System.Int32 | Spin | +| AwayScore | Away Score | Runs scored by away team | System.Int32 | Spin | +| OfficialDate | Official Date | Local game date | System.DateTimeOffset | [DateTime](../../DataPortals/05.Control-Details/DateTime.md) | + +![!DataPortal Games Sheet](img/PFPortalGames.png) + +One row per field. Column A is the name the sync module matches against, and column B is only the label a person sees. + +Now let's go to the [`Categories`](../../DataPortals/05.Categories.md) sheet, so we can define the times of day we referenced as `Form.DayNight`. Here, "DayNight" is the header of a column of the Categories sheet. We add in the two values `day` and `night`, spelled exactly as the MLB Stats API returns them. + +| DayNight | +| -------- | +| day | +| night | + +![!DataPortal Categories Sheet](img/PFPortalCategories.png) + +### MLBParkFactorHome Container Page + +Now let's go back to the `MLBParkFactorHome` sheet we referenced in the master sheet. Here we describe what to show as the main page of the DataPortal. We need to reference our `Games` container, and list what columns we want to display. + +![!DataPortal Home Container](img/PFPortalHomeContainer.png) + +For Type, enter `[Form.Games]`. The square brackets are what make this a repeating table rather than a single record. + +Under `Columns` enter: `[Form.Games.Park, Form.Games.Season, Form.Games.Month, Form.Games.DayNight, Form.Games.HomeScore, Form.Games.AwayScore, Form.Games.OfficialDate]` + +Optionally, the column [`SearchBoxes`](../../DataPortals/06.Setting-Details/SearchBoxes.md) set to `TRUE` will allow us to search on a column level, such as to only view games at a specific park. + +### Upload DataPortal + +On the New DataPortal page, either click the `Choose File` button, or drag your file over to the upload box, and in the background Composable creates your database. Leave `Select DataPortal Connection Key` alone, since it is optional, and without it Composable creates and manages the database for you. + +![!New DataPortal Upload Page](img/PFPortalUpload.png) + +The `Upload` panel on the right holds the connection key button at the top, the drag pad in the middle, and `Upload File` beneath it. `Download New Template File` at the bottom is where a blank workbook comes from if you want to start one from scratch. + +Once it's finished processing, click on the `Open DataPortal` button and you'll be brought to the homepage of your DataPortal, which will look empty, since we haven't added any data. After running the `mlballparksync` DataFlow (or whatever you named your sync DataFlow) from the previous tutorial, the same page looks like this. + +![!Games Grid in the DataPortal](img/PFPortalGrid.png) + +The seven fields appear as sortable, searchable columns, with `Total: 48494` at the bottom left once the sync has run. + +Note the portal's ID, which is the number in the url, `DataPortal.aspx#/form/`. This is the value that goes into the `FormId` input of the `DataPortal Sync` module and the `DataPortalId` input of the `DataPortal Query` module in the previous tutorial. Also note the database that Composable created to back the portal, which is named after the portal with a `Model` suffix, `MLBParkFactorModel`. We need that name in the next tutorial. + +!!! note + The workbook stays the source of truth. To change the model you edit it and [reupload it](../../DataPortals/10.UpdateDataPortals.md) on the portal's Manage page. Renames are the trap, since a DataPortal cannot detect that a field was renamed, so changing a `Name` deletes the old field and adds a new one, taking its data with it. Change the `DisplayName` instead when you only want the label to read differently. + +!!! note + Because `DayNight` is a `Category` whose members live on the `Categories` sheet, Composable stores it in its own lookup table. The `Games` table in the portal's database carries a `DayNight_Id` column pointing at a `DayNights` table, rather than the string itself. That decides how we write SQL in the next tutorial, and it is the easiest thing in this pipeline to get wrong. + +## Next Steps + +With an excel file and a two module DataFlow, we've created a database and inserted 48,000 games without writing any SQL. Next, we query that data with a [QueryView](3-QueryView.md). diff --git a/docs/PLS/MLB-Park-Factor/3-QueryView.md b/docs/PLS/MLB-Park-Factor/3-QueryView.md new file mode 100644 index 0000000..596f63d --- /dev/null +++ b/docs/PLS/MLB-Park-Factor/3-QueryView.md @@ -0,0 +1,104 @@ +--- +title: Composable Docs +summary: Storing database credentials in a Key and writing the SQL that turns stored games into park factors +authors: + - Composable Analytics, Inc. +date: 2026-08-19 +some_url: https://docs.composable.ai + +--- + +# Calculating Park Factors in a QueryView + +QueryViews provide querying and exploration of data stored in a database in an interactive web-based environment. In this tutorial, we will continue the series using the game data [loaded into a DataPortal](2-DataPortal.md), computing the park factor itself in SQL and reviewing the result as a grid. + +The calculation is a ratio of two averages. For each park, calendar month and time of day, we take that park's average runs per game, and divide it by the league's average runs for the same month and time of day. Pooling every July together, rather than each individual July, keeps the sample large enough to be meaningful. + +## Accessing the Database with a Key + +To access any database, you need log-in credentials. When we created the DataPortal, this does not create credentials, so you will need to get in touch with your Composable administrator/DBA to create credentials to access the `MLBParkFactorModel` database before continuing. Note that the database is named after the DataPortal with a `Model` suffix, and not a name of your choosing. + +With your database credentials, you can store them securely in Composable Key Vault as a [Key](../../Keys/01.Overview.md). + +Go to the `Create New` Keys page, and select `Database Connection Settings` as the `Property Type`. + +Then enter the following values into the fields, including the credentials that were set up. Because this is the local Composable instance the `Host` is `.`. If accessing a database on another server, the host is the IP address of the server. + +| Field | Entry | +| -------------------- | ------------------------------------------------------------ | +| Name | mlballpark | +| Description | | +| Host | . | +| Database | MLBParkFactorModel (name of the DataPortal Database) | +| Username | | +| Password | | +| Connection parameter | TrustServerCertificate = yes | + +The connection parameter is required, not optional. ODBC Driver 18 turns encryption on by default, so without `TrustServerCertificate=yes` the connection fails with *"SSL Provider: The certificate chain was issued by an authority that is not trusted."* + +The login also needs read access to this particular database. The public login that Composable sets up at install time is granted rights on the Composable databases only, and a DataPortal database is created later, at runtime, so it is not covered. Your DBA can grant it with two statements. + +```sql +USE MLBParkFactorModel; +CREATE USER CompAnalyticsPublicUser FOR LOGIN CompAnalyticsPublicUser; +ALTER ROLE db_datareader ADD MEMBER CompAnalyticsPublicUser; +``` + +Save the Key, and now we can get started on creating a QueryView. + +## Querying Park Factors in a QueryView + +### Create a New QueryView + +Now go to the QueryView menu, and select `Create New`. Start with the `Info` button on the left side panel, and enter something like `mlballpark_query` as the `Name`. Then move to the `Connection` panel, click the `Select Connection` button, and choose the `mlballpark` Key (or whatever you named your Key) we just created. + +Now is a good time to hit the `Save` button in the top right. You cannot run a QueryView if it has not been saved. + +### Writing a Query + +Our data was loaded into the `Games` table. Going back to the DataPortal tutorial, we named the container `Games`, and the database creation process of a DataPortal will pluralize names, which for this container leaves the name unchanged. + +Recall that `DayNight` is a picklist, so the games table stores a `DayNight_Id` and the readable value lives in a `DayNights` lookup table. That is why the query joins the two. `RIGHT(g.Month, 2)` pulls the calendar month out of the `YYYY-MM` string, so that all eleven Julys are pooled together. + +```sql +SELECT + p.Park, + p.CalendarMonth, + p.DayNight, + p.ParkAvgRuns, + l.LeagueAvgRuns, + p.ParkAvgRuns / l.LeagueAvgRuns AS ParkFactor, + (p.ParkAvgRuns / l.LeagueAvgRuns - 1) * 100 AS ParkFactorDeviationPct +FROM + (SELECT g.Park, RIGHT(g.Month, 2) AS CalendarMonth, d.Value AS DayNight, + AVG(CAST(g.HomeScore AS FLOAT) + CAST(g.AwayScore AS FLOAT)) AS ParkAvgRuns + FROM Games g JOIN DayNights d ON d.Id = g.DayNight_Id + GROUP BY g.Park, RIGHT(g.Month, 2), d.Value) p +JOIN + (SELECT RIGHT(g.Month, 2) AS CalendarMonth, d.Value AS DayNight, + AVG(CAST(g.HomeScore AS FLOAT) + CAST(g.AwayScore AS FLOAT)) AS LeagueAvgRuns + FROM Games g JOIN DayNights d ON d.Id = g.DayNight_Id + GROUP BY RIGHT(g.Month, 2), d.Value) l +ON p.CalendarMonth = l.CalendarMonth AND p.DayNight = l.DayNight +``` + +Paste the query into the `Query Template` pane on the left. As we're typing, the `Sample Output` pane on the right is generating what the final query will look like, with `ORDER BY 1`, `OFFSET 0 ROWS` and `FETCH NEXT 50 ROWS ONLY` appended by the [paging](../../QueryViews/Paging.md) wrapper. + +![!Query Template and Sample Output in the QueryView editor](img/PFQueryViewEditor.png) + +The left rail `CONNECTION` button is where the connection key is selected. + +!!! note + Leave the `ORDER BY` out of the query itself and use the QueryView's own Order configuration on the `Info` panel instead. The paging wrapper adds its own ordering, and an inner `ORDER BY` collides with it. + +Press the Execute button and take a look at the results. Once the sync DataFlow has loaded the portal, this returns 593 rows across seven columns, one per park, calendar month and time of day combination. The header reads *Displaying results 1 to 50 of 593*. + +![!Park factor results in the QueryView](img/PFQueryViewResults.png) + +A `ParkFactor` above 1 is a hitter-friendly park-month, and below 1 is pitcher-friendly. `ParkFactorDeviationPct` states the same thing as a percentage away from the league average. + +## Next Steps + +The QueryView is a grid, which is ideal for reporting and for exploring the data on your own. To make the same numbers readable at a glance, we chart them in a [WebApp](4-WebApp.md). + +QueryViews can do considerably more than the single query we wrote here. [Inputs](../../QueryViews/Inputs.md) make the results interactive, [Hyperlinks](../../QueryViews/Hyperlinks.md) add links built from each row, and [Actions](../../QueryViews/Actions.md) connect results back to DataFlows. diff --git a/docs/PLS/MLB-Park-Factor/4-WebApp.md b/docs/PLS/MLB-Park-Factor/4-WebApp.md new file mode 100644 index 0000000..078462c --- /dev/null +++ b/docs/PLS/MLB-Park-Factor/4-WebApp.md @@ -0,0 +1,463 @@ +--- +title: Composable Docs +summary: Building the WebApp that charts day and night park factors, and verifying the finished pipeline +authors: + - Composable Analytics, Inc. +date: 2026-08-19 +some_url: https://docs.composable.ai + +--- + +# Charting Park Factors in a WebApp + +A [WebApp](../../WebApps/01.Overview.md) hosts your own HTML, JavaScript and CSS inside Composable, served from the platform and secured by the same permissions as every other resource. In this tutorial, we build the page that reads the JSON from the [API DataFlow](1-DataFlows.md) and draws it. + +The page fetches the JSON once, fills a ballpark dropdown from the distinct `Park` values, and draws two [Chart.js](https://www.chartjs.org/) series, day and night, both on the same axis and both expressed as park factor. Above the center line the park plays hitter-friendly, and below it plays pitcher-friendly. The axis fits itself to whichever park is selected, so that an extreme park is not drawn off the top of the chart. + +![!The finished WebApp](img/PFWebAppResult.png) + +At AT&T Park, night (blue) sits below 1.00 all season, while day (orange) swings from 1.19 in April down to 0.73 in September. Both series share one scale, where 1.00 is the league average for that month and time of day. + +## Creating the WebApp + +Go to the WebApp menu, select [Create New](../../WebApps/02.WebApp-Create-New.md), and name it something like `mlballpark_TOD_webapp`, with `index.html` as the entrypoint. + +In the [editor](../../WebApps/03.WebApp-Editor.md), add three files under `Project Structure` on the left, `index.html`, `script.js` and `style.css`, and paste in the listings below. Each file opens in its own tab in the code pane. + +![!The WebApp editor](img/PFWebAppEditor.png) + +`Project Structure` on the left lists the three files, and the code pane holds the open tab. `Save` and `View WebApp` sit in the upper right. + +In `script.js`, set `API_URL` to the activation url of the `mlballpark_TOD_api` DataFlow (or whatever you named your API DataFlow). Then press `Save`, followed by `View WebApp` to open the page. + +!!! note + The `?v=` query string on the stylesheet and script tags is deliberate. Browsers cache WebApp resources aggressively, so bump that number whenever you edit `script.js` or `style.css`, or you will be looking at yesterday's file while wondering why your change did nothing. + +## The Page Markup + +The markup is deliberately thin: a decorative sky with a pixel sun and moon, a dropdown, a canvas for Chart.js with a zone label above and below it, and a callout beneath for the most extreme month. + +```html + + + + + MLB Day/Night Park Factor + + + + + + +
+
+

Day vs. Night Park Factor

+

Park factor by month (2016–2025), above the line plays hitter-friendly, below plays pitcher-friendly

+
+ +
+ + +
+ +
+
Hitter-Friendly
+ +
Pitcher-Friendly
+
+ +
+
+ + + + +``` + +## The Page Script + +The script fetches the JSON, builds the dropdown, and redraws the chart whenever the selection changes. Both series are plotted on the same axis. The axis bound is computed from the data, rounded up to the nearest 5%, with a floor of 10%, which is what keeps Coors Field on the page. + +```javascript +const API_URL = "/CompApp/services/WebActivationService.svc/Activate?appId=89113"; // your Section 1.3 DataFlow + +const MONTH_LABELS = { "04": "Apr", "05": "May", "06": "Jun", "07": "Jul", "08": "Aug", "09": "Sep" }; +const MONTH_ORDER = ["04", "05", "06", "07", "08", "09"]; + +let allRows = []; +let chart = null; + +async function loadData() { + let json; + try { + const res = await fetch(API_URL); + if (!res.ok) { + throw new Error(`${res.status} ${res.statusText}`); + } + json = await res.json(); + } catch (err) { + document.getElementById("callout").textContent = + `Could not load park factor data: ${err.message}`; + return; + } + allRows = json.Rows || []; + + populateParkSelector(); + renderChart(document.getElementById("parkSelect").value); +} + +function populateParkSelector() { + const parks = [...new Set(allRows.map(r => r.Park))].sort(); + const select = document.getElementById("parkSelect"); + select.innerHTML = parks.map(p => ``).join(""); + select.addEventListener("change", e => renderChart(e.target.value)); +} + +function renderChart(park) { + const rows = allRows.filter(r => r.Park === park); + + const dayData = MONTH_ORDER.map(m => { + const row = rows.find(r => r.CalendarMonth === m && r.DayNight === "day"); + return row ? row.ParkFactorDeviationPct : 0; + }); + + const nightData = MONTH_ORDER.map(m => { + const row = rows.find(r => r.CalendarMonth === m && r.DayNight === "night"); + return row ? row.ParkFactorDeviationPct : 0; + }); + + // a fixed +/-15 clipped real parks (AT&T Park runs to -27% in September), so scale to the data + const spread = Math.max(10, ...dayData.concat(nightData).map(Math.abs)); + const bound = Math.ceil(spread / 5) * 5; + + const ctx = document.getElementById("parkChart").getContext("2d"); + if (chart) chart.destroy(); + + chart = new Chart(ctx, { + type: "line", + data: { + labels: MONTH_ORDER.map(m => MONTH_LABELS[m]), + datasets: [ + { + label: "Day", + data: dayData, + borderColor: "#ff9f43", + backgroundColor: "rgba(255, 159, 67, 0.35)", + fill: "origin", + tension: 0.3, + pointRadius: 4 + }, + { + label: "Night", + data: nightData, + borderColor: "#1e3a8a", + backgroundColor: "rgba(30, 58, 138, 0.4)", + fill: "origin", + tension: 0.3, + pointRadius: 4 + } + ] + }, + options: { + responsive: true, + aspectRatio: 2.1, + scales: { + y: { + min: -bound, + max: bound, + grid: { + color: ctx => ctx.tick.value === 0 ? "#333" : "#ddd", + lineWidth: ctx => ctx.tick.value === 0 ? 2 : 1 + }, + ticks: { + // series carry % deviation; the axis is quoted as the park factor it represents + callback: v => (1 + v / 100).toFixed(2) + }, + title: { + display: true, + text: "Park Factor (1.00 = league average)", + font: { weight: "600" } + } + } + }, + plugins: { + tooltip: { + callbacks: { + label: ctx => { + const dsLabel = ctx.dataset.label; + const val = ctx.raw; + const sign = val > 0 ? "+" : ""; + const parkFactor = 1 + val / 100; + return `${dsLabel}: ${parkFactor.toFixed(2)} (${sign}${val.toFixed(1)}% vs league avg)`; + } + } + } + } + } + }); + + renderCallout(rows); +} + +function renderCallout(rows) { + // only months the chart actually plots -- October rows exist in the data and would + // otherwise win "most extreme" while having no label and no point on the canvas + const charted = rows.filter(r => MONTH_ORDER.includes(r.CalendarMonth)); + if (!charted.length) return; + const extreme = charted.reduce((a, b) => Math.abs(b.ParkFactorDeviationPct) > Math.abs(a.ParkFactorDeviationPct) ? b : a); + const dir = extreme.ParkFactorDeviationPct > 0 ? "hitter-friendly" : "pitcher-friendly"; + document.getElementById("callout").textContent = + `Most extreme: ${MONTH_LABELS[extreme.CalendarMonth]} (${extreme.DayNight}) — park factor ${extreme.ParkFactor.toFixed(2)}, ${Math.abs(extreme.ParkFactorDeviationPct).toFixed(1)}% ${dir}`; +} + +loadData(); +``` + +## The Page Styles + +The stylesheet paints the pixel art sun and moon with `clip-path` and `repeating-conic-gradient`, animated in `steps()` so that the motion stays blocky rather than smooth, and splits the background vertically between the day and night halves of the page. + +```css +:root { + --day-color: #ff9f43; + --night-color: #1e3a8a; + --panel: rgba(255, 255, 255, 0.9); + --ink: #14182b; + --pixel: 8px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: 'Segoe UI', system-ui, sans-serif; + color: var(--ink); + min-height: 100vh; + image-rendering: pixelated; + /* hard-stop bands instead of a blend: the split reads as blocky columns, not a gradient */ + background: + linear-gradient(to right, + #f7f9ff 0 34%, + #e4ecff 34% 41%, + #b9c9ee 41% 47%, + #7288c4 47% 53%, + #33477f 53% 60%, + #16213e 60% 100%); + background-attachment: fixed; +} + +/* checkerboard texture: gives every band a visible pixel grain */ +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background-image: repeating-conic-gradient(rgba(0,0,0,0) 0 25%, rgba(0,0,0,0.035) 0 50%); + background-size: var(--pixel) var(--pixel); +} + +/* ---------- sky decorations ---------- */ + +.sky { position: fixed; inset: 0; pointer-events: none; z-index: 0; } + +.sun { + position: absolute; + top: 20px; + left: 20px; + width: 112px; + height: 112px; +} + +.sun-rays { + position: absolute; + inset: 0; + background: repeating-conic-gradient( + from 0deg, + #ffb703 0deg 7deg, + rgba(255, 183, 3, 0) 7deg 45deg); + /* ring mask so the rays sit outside the core, in hard steps */ + -webkit-mask-image: radial-gradient(circle, transparent 0 33%, #000 33% 50%, transparent 50%); + mask-image: radial-gradient(circle, transparent 0 33%, #000 33% 50%, transparent 50%); + animation: ray-spin 8s steps(8) infinite; +} + +.sun-core { + position: absolute; + inset: 30%; + background: + linear-gradient(to bottom, + #ffe08a 0 25%, + #ffcb47 25% 55%, + #ffa41b 55% 80%, + #f08a00 80% 100%); + /* stepped polygon: a circle drawn out of square pixels */ + clip-path: polygon( + 33% 0, 67% 0, 67% 11%, 89% 11%, 89% 33%, + 100% 33%, 100% 67%, 89% 67%, 89% 89%, 67% 89%, + 67% 100%, 33% 100%, 33% 89%, 11% 89%, 11% 67%, + 0 67%, 0 33%, 11% 33%, 11% 11%, 33% 11%); + animation: sun-pulse 2.4s steps(2) infinite alternate; +} + +.moon { + position: absolute; + top: 20px; + right: 20px; + width: 96px; + height: 96px; + animation: moon-bob 6s steps(4) infinite alternate; +} + +.moon-disc { + position: absolute; + inset: 0; + box-shadow: 0 0 24px 6px rgba(226, 232, 255, 0.25); + background: + linear-gradient(to bottom, + #fdfbef 0 30%, + #ece7d2 30% 62%, + #d2ccb4 62% 100%); + clip-path: polygon( + 33% 0, 67% 0, 67% 11%, 89% 11%, 89% 33%, + 100% 33%, 100% 67%, 89% 67%, 89% 89%, 67% 89%, + 67% 100%, 33% 100%, 33% 89%, 11% 89%, 11% 67%, + 0 67%, 0 33%, 11% 33%, 11% 11%, 33% 11%); + animation: moon-glow 3.6s steps(2) infinite alternate; +} + +.moon-crater { + position: absolute; + background: #b9b199; +} + +.moon-crater.c1 { width: 14px; height: 14px; top: 28%; left: 27%; } +.moon-crater.c2 { width: 9px; height: 9px; top: 56%; left: 55%; } +.moon-crater.c3 { width: 7px; height: 7px; top: 33%; left: 62%; } + +@keyframes ray-spin { to { transform: rotate(360deg); } } +@keyframes sun-pulse { to { filter: brightness(1.18); } } +@keyframes moon-bob { to { transform: translateY(10px); } } +@keyframes moon-glow { to { filter: brightness(1.12) drop-shadow(0 0 6px rgba(226, 232, 255, 0.45)); } } + +@media (prefers-reduced-motion: reduce) { + .sun-rays, .sun-core, .moon, .moon-disc { animation: none; } +} + +/* ---------- content ---------- */ + +.app { + position: relative; + z-index: 1; + max-width: 900px; + margin: 0 auto; + padding: 150px 20px 40px; +} + +header h1 { + margin: 0; + font-size: 1.8rem; + text-align: center; + background: var(--panel); + padding: 10px; +} + +.subtitle { + text-align: center; + color: #333c56; + margin: 0; + padding: 6px 10px 10px; + background: var(--panel); +} + +.controls { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + margin: 24px 0; + padding: 10px; + background: var(--panel); +} + +#parkSelect { + padding: 8px 14px; + border: 2px solid #47506e; + border-radius: 0; + font-size: 1rem; + background: #fff; +} + +.chart-zone { + position: relative; + display: flex; + flex-direction: column; + gap: 6px; + background: var(--panel); + padding: 16px; + border: 2px solid #47506e; +} + +canvas { position: relative; z-index: 1; } + +/* in flow rather than absolute: an overlaid label collided with the x-axis month ticks */ +.zone-label { + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.04em; + color: #000; +} + +.callout { + margin-top: 20px; + text-align: center; + font-size: 1.1rem; + font-weight: 500; + padding: 12px; + background: var(--panel); + border: 2px solid #47506e; +} +``` + + +## Verifying the Whole Pipeline + +Work back through the pipeline in order. Each check tells you which stage is at fault if the numbers are wrong. + +- Run `mlballparksync` (or whatever you named your sync DataFlow) and confirm that `Counts` reports about 48,000 records inserted, and that `Errors` is empty. +- Open `mlballpark_query` (or whatever you named your QueryView) and confirm 593 rows. +- Run `mlballpark_TOD_api` (or whatever you named your API DataFlow) from the Designer and confirm that its `Web Send` output carries the same 593 rows and the seven expected column names. +- Open the WebApp, pick a ballpark, and confirm that the chart draws. + +If something is off, these are the usual causes. + +| Symptom | Cause | +| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| QueryView fails with *"The certificate chain was issued by an authority that is not trusted"* | The Key is missing `TrustServerCertificate=yes`. ODBC Driver 18 encrypts by default. | +| QueryView fails with *"Cannot open database ... requested by the login"* | The login has no rights on the portal database. Run the grant from the QueryView tutorial. | +| QueryView fails with *"Login failed for user"* | The Key names a database that does not exist. The portal's database is `Model`. | +| `Invalid column name 'DayNight'` | `DayNight` is a picklist. Join `DayNights` on `DayNight_Id` instead of selecting the column directly. | +| The page loads but the chart is empty, and the console shows a `500` | The viewer lacks Execute permission on the API DataFlow. | +| An edit to `script.js` or `style.css` has no effect | Cached resource. Bump the `?v=` query string and hard-reload. | +| The sync reports errors for every row | A container field name does not match its source column. Compare the DataPortal field table against the `ColumnNames` in the first DataFlow. | + +## Next Steps + +The pipeline is a complete round trip: a public API becomes stored records, stored records become a query, and the query becomes a page someone can actually read. Each piece is replaceable, so swapping the JSON paths and the portal fields makes the same skeleton serve any other API. + +A few natural extensions to try on your own: + +- Schedule the sync. Add a timer activation to `mlballparksync` (or whatever you named your sync DataFlow) so that the portal refreshes nightly during the season. Timer activation runs in the Composable Activation Service, so confirm that service is running before relying on it. +- Widen the window. `MONTH_ORDER` in `script.js` covers April through September. October games are in the portal but off the chart, so add `"10"` to include them, and expect noisy values from the small sample. +- Split by season rather than by month. The portal keeps `Season` on every row, so a year over year comparison for a single park is a small change to the aggregation. diff --git a/docs/PLS/MLB-Park-Factor/img/MLBParkFactorDataPortal.xlsx b/docs/PLS/MLB-Park-Factor/img/MLBParkFactorDataPortal.xlsx new file mode 100644 index 0000000..29d5ad0 Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/MLBParkFactorDataPortal.xlsx differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFApiAppId.png b/docs/PLS/MLB-Park-Factor/img/PFApiAppId.png new file mode 100644 index 0000000..71cf92f Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFApiAppId.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFApiModules.png b/docs/PLS/MLB-Park-Factor/img/PFApiModules.png new file mode 100644 index 0000000..5bfcbb2 Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFApiModules.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFParseAndClean.png b/docs/PLS/MLB-Park-Factor/img/PFParseAndClean.png new file mode 100644 index 0000000..93cdf6b Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFParseAndClean.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFPortalCategories.png b/docs/PLS/MLB-Park-Factor/img/PFPortalCategories.png new file mode 100644 index 0000000..5c061e1 Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFPortalCategories.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFPortalGames.png b/docs/PLS/MLB-Park-Factor/img/PFPortalGames.png new file mode 100644 index 0000000..ec03a4d Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFPortalGames.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFPortalGrid.png b/docs/PLS/MLB-Park-Factor/img/PFPortalGrid.png new file mode 100644 index 0000000..8fd5e1f Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFPortalGrid.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFPortalHomeContainer.png b/docs/PLS/MLB-Park-Factor/img/PFPortalHomeContainer.png new file mode 100644 index 0000000..6b3956b Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFPortalHomeContainer.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFPortalMaster.png b/docs/PLS/MLB-Park-Factor/img/PFPortalMaster.png new file mode 100644 index 0000000..405f06b Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFPortalMaster.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFPortalUpload.png b/docs/PLS/MLB-Park-Factor/img/PFPortalUpload.png new file mode 100644 index 0000000..c0a4488 Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFPortalUpload.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFQueryViewEditor.png b/docs/PLS/MLB-Park-Factor/img/PFQueryViewEditor.png new file mode 100644 index 0000000..b1d5a07 Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFQueryViewEditor.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFQueryViewResults.png b/docs/PLS/MLB-Park-Factor/img/PFQueryViewResults.png new file mode 100644 index 0000000..107c422 Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFQueryViewResults.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFSeasonLoop.png b/docs/PLS/MLB-Park-Factor/img/PFSeasonLoop.png new file mode 100644 index 0000000..3f3c9ad Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFSeasonLoop.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFSyncPortal.png b/docs/PLS/MLB-Park-Factor/img/PFSyncPortal.png new file mode 100644 index 0000000..1a402aa Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFSyncPortal.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFTeamList.png b/docs/PLS/MLB-Park-Factor/img/PFTeamList.png new file mode 100644 index 0000000..e8e07fd Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFTeamList.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFUnionOutput.png b/docs/PLS/MLB-Park-Factor/img/PFUnionOutput.png new file mode 100644 index 0000000..b1a3652 Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFUnionOutput.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFWebAppEditor.png b/docs/PLS/MLB-Park-Factor/img/PFWebAppEditor.png new file mode 100644 index 0000000..dad7363 Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFWebAppEditor.png differ diff --git a/docs/PLS/MLB-Park-Factor/img/PFWebAppResult.png b/docs/PLS/MLB-Park-Factor/img/PFWebAppResult.png new file mode 100644 index 0000000..67d1d2b Binary files /dev/null and b/docs/PLS/MLB-Park-Factor/img/PFWebAppResult.png differ diff --git a/mkdocs.yml b/mkdocs.yml index 87860b5..1c478f8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -236,6 +236,12 @@ nav: - 'Lab 1: DataFlow - Reading Data from the Web': 'Tutorial/ReadingBlueBikes.md' - 'Lab 2: DataPortal - Creating a Database and Inserting Data': 'Tutorial/BlueBikesDataPortal.md' - 'Lab 3: QueryView - Querying, Filtering, and Generating an Interactive Map': 'Tutorial/QueryViewDataPortal.md' + - 'MLB Park Factor': + - 'MLB Park Factor Project goals': 'PLS/MLB-Park-Factor/0-MLBParkFactor-Goals.md' + - 'DataFlow - Reading Ballpark Data from the MLB Stats API': 'PLS/MLB-Park-Factor/1-DataFlows.md' + - 'DataPortal - Creating a Database and Inserting Game Data': 'PLS/MLB-Park-Factor/2-DataPortal.md' + - 'QueryView - Calculating Park Factors from Stored Games': 'PLS/MLB-Park-Factor/3-QueryView.md' + - 'WebApp - Charting Park Factors in the Browser': 'PLS/MLB-Park-Factor/4-WebApp.md' - Videos: - 'Video Tutorials': 'Videos/Composable-Videos.md' - References: