TALL Stack: Sifting the Data

Date Created: 05-May-2025

Sifting for old clues

Virginia State Parks staff, CC BY 2.0, via Wikimedia Commons

The Problem

At the time of writing, I have over 17,000 observations across over 6,500 taxa, and a lot more to add. That represents a heck of a lot of scrolling on the respective index pages, when you might only be interested in a very small subset of the data.

The Solution

My checklist functionality resolves this to some extent because, if, for example, you are interested in butterflies, I already have a checklist for those, which shows all my taxa in the Papilionoidea superfamily, nicely arranged and grouped taxonomically. However, I have checklists defined for only six taxonomic groups (I must create some more).

In the meantime, and useful in its own right, I have provided filters on the Taxa and Observations index pages. For taxa, the filter is based only on taxonomy, allowing a root taxon to be selected. Sorting can be based on the most recently added taxa or taxonomically. For Observations, the root taxon filter is also used, along with a filter on observation date. Sort can be based on observation date, date added or date last modified, and can be in ascending or descending order.

In addition, the filters take note of query parameters, to allow the page to be prefiltered and links of such to be shared. The filters use Livewire to allow the page contents to reflect the filter, as soon as a filter option is changed. I will explain how it works for the Observations filter. The Taxa filter follows the same principle but is slightly simpler.

The FilterObservations component

class FilterObservations extends Component
{
    #[Url(history: true)]
    public string $dateFrom;
    #[Url(history: true)]
    public string $dateTo;
    #[Url(history: true)]
    public ?int $rootTaxon;
    #[Url(history: true)]
    public string $sortBy;
    #[Url(history: true)]
    public string $sortOrder;
    public string $rootTaxonName = '';

    public function render(ObservationQueryBuilder $queryBuilder)
    {
        $filterChanged = false;
        if (empty($this->rootTaxon)) {
            $this->rootTaxon = 0;
            $this->rootTaxonName = '';
        } else {
            $this->rootTaxonName = Taxon::find($this->rootTaxon)->scientific_name;
        }
        if ($queryBuilder->rootTaxon != $this->rootTaxon) {
            $queryBuilder->rootTaxon = $this->rootTaxon;
            $filterChanged = true;
        }
        if (empty($this->dateFrom)) {
            $this->dateFrom = '';
        }
        if ($queryBuilder->dateFrom != $this->dateFrom) {
            $queryBuilder->dateFrom = $this->dateFrom;
            $filterChanged = true;
        }
        if (empty($this->dateTo)) {
            $this->dateTo = '';
        }
        if ($queryBuilder->dateTo != $this->dateTo) {
            $queryBuilder->dateTo = $this->dateTo;
            $filterChanged = true;
        }
        if (empty($this->sortBy)) {
            $this->sortBy = $queryBuilder->defaultSort['by'];
        }
        if ($queryBuilder->sortBy != $this->sortBy) {
            $queryBuilder->sortBy = $this->sortBy;
            $filterChanged = true;
        }
        if (empty($this->sortOrder)) {
            $this->sortOrder = $queryBuilder->defaultSort['order'];
        }
        if ($queryBuilder->sortOrder != $this->sortOrder) {
            $queryBuilder->sortOrder = $this->sortOrder;
            $filterChanged = true;
        }
        if ($filterChanged) {
            $query = $queryBuilder->buildQuery();
            $this->dispatch('filterChanged', sqlStatement: $query->toSql(), bindings: $query->getBindings());
        }

        return view('livewire.filter-observations', ['defaultSort' => $queryBuilder->defaultSort]);
    }

    public function clear()
    {
        $this->reset();
    }
}

The properties of the component are simply both ends of a date range, the root taxon and the two parts of the sort (what to sort by and in what order). The important thing to note, is the use of the #[Url(history: true)] Livewire attribute, which adds each property and its value to the URL query string, and ensures that changes in the filter properties are recorded in the browser history, allowing users to go backwards and forwards between different filtered sets. The rootTaxonName property is simply to show the name under the taxon filter field, when the taxon picker is not open.

Another important point is that the FilterObservations component doesn't actually perform any filtering. Instead, if any filter value has changed, the ObservationQueryBuilder helper class builds a SQL query that is dispatched via a filterChanged event. That event is handled by the LoadMore component, which is explained in another article. I will explain the ObservationQueryBuilder helper class after showing the Blade for the FilterObservations component.

<div>
    <div
        x-data="{
            dateFrom: $wire.entangle('dateFrom').live,
            dateTo: $wire.entangle('dateTo').live,
            rootTaxon: $wire.entangle('rootTaxon').live,
            rootTaxonName: $wire.entangle('rootTaxonName').live,
            sortBy: $wire.entangle('sortBy').live,
            sortOrder: $wire.entangle('sortOrder').live,
            filterOpen: false,
            dateFilterOpen: false,
            rootFilterOpen: false,
            sortOpen: false,
            treeViewOpen: false, // Used by the nested TreeView component
        }"
        x-init = "filterOpen = rootTaxon || dateFrom || dateTo";
        x-cloak
        x-on:date-from-changed.window="dateFrom = $event.detail.dateFrom; dateFilterOpen=false;"
        x-on:date-to-changed.window="dateTo = $event.detail.dateTo; dateFilterOpen=false;"
        x-on:root-taxon-changed.window="rootTaxon = $event.detail.selection; setTaxonName(); rootFilterOpen=false;"
    >
        <div class="flex justify-between items-center">
            <h1 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Observations') }}
            </h1>
            <div x-on:click="filterOpen = ! filterOpen" type="button" class="inline-flex items-center" title="{{ __('Show/Hide Filter/Sort') }}">
                <x-icons.filter class="cursor-pointer text-gray-400 hover:text-gray-500" width="16" />
            </div>
        </div>
        <div x-show="filterOpen" class="relative mt-2 border-t pt-3">
            <div class="flex flex-col sm:flex-row space-y-3 sm:space-x-3 sm:items-baseline">
                <div class="text-ts font-bold">{{ __('Filter') }}</div>
                <div>
                    <x-dropdown-button toggle="dateFilterOpen" label="Observation Date" />
                    <div x-show.transition="dateFilterOpen" x-on:keydown.escape="dateFilterOpen = false" x-on:click.away="dateFilterOpen = false">
                        <x-date-range-picker :start-date="$dateFrom" :end-date="$dateTo" />
                    </div>
                    <div x-show.transition="!dateFilterOpen && (dateFrom || dateTo)" class="text-xs">
                        <p x-text="dateFrom + ' - ' + dateTo"></p>
                    </div>
                </div>
                <div class="relative">
                    <x-dropdown-button toggle="rootFilterOpen" label="Root Taxon" />
                    <div x-show.transition="rootFilterOpen" x-on:keydown.escape="rootFilterOpen = false" x-on:click.away="rootFilterOpen = false" class="absolute left-0 top-10 z-40 w-full sm:w-120">
                        <input type="hidden" x-ref="rootTaxon" x-model="rootTaxon" />
                        <x-root-taxon-picker />
                    </div>
                    <div x-show.transition="!rootFilterOpen && rootTaxon" class="text-xs">
                        <p x-text="rootTaxonName"></p>
                    </div>
                </div>
                <div>
                    <x-dropdown-button toggle="sortOpen" label="Sort" />
                    <div x-show.transition="sortOpen" x-on:keydown.escape="sortOpen = false" x-on:click.away="sortOpen = false">
                        <div class="border rounded-md mt-3">
                            <select x-model="sortBy" class="focus:outline-none border-0 py-1 rounded-l-md">
                                <option value="Time Observed">Time Observed</option>
                                <option value="Time Recorded">Time Recorded</option>
                                <option value="Time Updated">Time Updated</option>
                            </select>
                            <select x-model="sortOrder" class="focus:outline-none border-0 py-1 rounded-r-md bg-gray-200">
                                <option value="Ascending">Ascending</option>
                                <option value="Descending">Descending</option>
                            </select>
                        </div>
                    </div>
                    <div x-show.transition="!sortOpen" class="text-xs">
                        <p x-text="sortBy + ' ' + sortOrder"></p>
                    </div>
                </div>
            </div>
            <div class="absolute top-3 right-0">
                <x-button x-on:click="window.location.href = window.location.pathname;">Clear Filter</x-button>
            </div>
        </div>
    </div>
</div>

The filter contains three parts:

  • a date range picker (using my own component)
  • a tree view component, explained in another article
  • a control with two select elements, for the sort field and order, respectively

All controls have text underneath to display the chosen option, when the control is collapsed.

Initially the filter is hidden, and is toggled by the value of filterOpen. If the URL contains query parameters for either of the filters, the filter is opened in x-init.

The date-from-changed and date-to-changed events are dispatched by the date range picker component. The root-taxon-changed event is dispatched by the tree view component. It uses a compound key for each taxon, based on the component used and the taxon ID. Here we are only interested in the taxon ID, so we extract that to set the rootTaxon.

The Clear Filter button simply refreshes the page using window.location.href = window.location.pathname;. I tried using the reset() method of the Livewire component but it had the effect of writing 5 entries to the browser history, one for the clearing of each property of the filter.

The ObservationQueryBuilder helper class

The ObservationQueryBuilder helper class is used by the FilterObservations component to build an Eloquent query, from which the SQL statement and bindings are passed to the LoadMore component to fetch the models that match the filter properties. It is also used by the index method of the ObservationController to ensure that, when the page is first loaded, any existing query parameters are taken into account to filter/sort the results. ObservationQueryBuilder is included in the via dependency injection and defined as a singleton in AppServiceProvider. This is to ensure that its properties are maintained between its use in the ObservationController and FilterObservations component, avoiding an unnecessary refresh of the observations displayed on the page.

class ObservationQueryBuilder
{
    public string $dateFrom;
    public string $dateTo;
    public ?int $rootTaxon;
    public string $sortBy;
    public string $sortOrder;

    const SORT_OPTIONS = [
        'Time Observed' => 'observed_at',
        'Time Recorded' => 'added_to_inat_at',
        'Time Updated' => 'updated_in_inat_at'
    ];
    const SORT_ORDERS = ['Ascending' => 'ASC', 'Descending' => 'DESC'];
    public array $defaultSort = ['by' => 'Time Observed', 'order' => 'Descending'];

    public function __construct()
    {
        $this->dateFrom = '';
        $this->dateTo = '';
        $this->rootTaxon = 0;
        $this->sortBy = '';
        $this->sortOrder = '';
    }

    public function buildQuery()
    {
        $query = Observation::where('id', '>', 0);
        if (!empty($this->rootTaxon)) {
            $rootTaxon = Taxon::find($this->rootTaxon);
            $rootTaxonDescendantIds = array_values($rootTaxon->descendants->pluck('id')->toArray());
            $rootTaxonDescendantIds[] = $rootTaxon->id;
            $query->whereIn('taxon_id', $rootTaxonDescendantIds);
        }
        if ( ! empty($this->dateFrom) ) {
            $timeFrom = $this->dateFrom . ' 00:00:00';
        }
        if ( ! empty($this->dateTo) ) {
            $timeTo = $this->dateTo . ' 23:59:59';
        }
        if ( !empty($timeTo) && !empty($timeFrom) ) {
            $query->whereBetween('observed_at', [ $timeFrom, $timeTo ]);
        } elseif ( !empty($timeFrom) ) {
            $query->where('observed_at', '>=', $timeFrom );
        } elseif ( !empty($timeTo) ) {
            $query->where('observed_at', '<=', $timeTo );
        }
        $sortBy = empty($this->sortBy) ? 'observed_at' : self::SORT_OPTIONS[$this->sortBy];
        $sortOrder = empty($this->sortOrder) ? 'DESC' : self::SORT_ORDERS[$this->sortOrder];
        $query->orderBy($sortBy, $sortOrder)->orderBy('id', $sortOrder);

        return $query;
    }
}

Basically, all this is doing is dynamically building a query based on the filters. It has only two types of filter: one based on the observation date; and the other on the root taxon. It also has the option to sort by one of the three dates (when the observation took place, when it was added to iNaturalist, and when it was last updated in iNaturalist) and to choose between ascending or descending order. One of the great things about the Builder is that you can gradually and conditionally build up your query as long as you are calling methods that return a Builder (i.e. until you call any method to get the results).

It starts by adding a dummy WHERE clause. This is simply to give us a Builder object, to which we can add the rest of our query. The date filter will normally work with a date range, using Builder->whereBetween() to add a BETWEEN condition to the WHERE clause based on the selected date range. If only one end of the date range is supplied, it uses >= or <=, accordingly. The taxon filter gets an array of the ids of the descendant taxa of the root taxon (picked in the filter) and uses the Builder->whereIn() to add a IN condition to the WHERE clause.

The descendants are retrieved using a method on the HierarchicalModel class, which is inherited by the Taxon model.

public function getDescendantsAttribute()
{
    return static::where('set_start', '>', $this->set_start)
        ->where('set_end', '<', $this->set_end)
        ->orderBy('set_start', 'asc')
        ->get();
}

Note that this uses the Nested Sets Model, which is explained in another article.

The new SQL statement and its bindings are dispatched via a filterChanged event that will be picked up by the any LoadMore and LoadMoreButton components, and the set of observation tiles will be changed accordingly.