Developer Guide

Comprehensive developer documentation for extending Location Viewer with custom sources, template overrides, and advanced integrations.


Table of Contents


Plugin Architecture

Core Concepts

Location Viewer is built on three fundamental architectural patterns:

  1. Source Registry: Centralized registration system for location data sources
  2. Universal Interface: Consistent contract that all sources must implement
  3. Template Waterfall: Hierarchical template resolution system for customization

Directory Structure

location-viewer/
├── location-viewer.php              # Main plugin file
├── includes/
│   ├── class-lv-source-registry.php # Source registration manager
│   ├── interface-lv-source.php      # Source interface definition
│   ├── class-lv-post-type-source.php# Built-in post type source
│   ├── class-admin-settings.php     # Admin UI and settings
│   └── class-meta-boxes.php         # Location meta boxes
├── assets/
│   ├── js/
│   │   ├── blocks.js                # Gutenberg block
│   │   ├── frontend.js              # Frontend interactions
│   │   └── admin.js                 # Admin interface scripts
│   └── css/
│       ├── location-viewer.css      # Frontend styles
│       └── admin.css                # Admin styles
├── templates/
│   ├── grid.php                     # Default grid template
│   └── popup.php                    # Default popup template
└── docs/
    ├── user-guide.html              # User documentation
    └── developer-guide.html         # This file

Creating Custom Sources

Source Interface

All sources must implement the LV_Source_Interface interface:

interface LV_Source_Interface {
    /**
     * Get items from this source with optional filters
     * @param array $filters Associative array of filter criteria
     * @param int $limit Maximum number of items to return
     * @return array Array of normalized items
     */
    public function get_items($filters = [], $limit = 50);
    /**
     * Get a single item by ID
     * @param string|int $id Item identifier
     * @return array|null Normalized item or null if not found
     */
    public function get_item($id);
    /**
     * Get available filters for this source
     * @return array Array of filter definitions
     */
    public function get_filters();
    /**
     * Normalize raw data into standard structure
     * @param mixed $raw_data Raw item data from source
     * @return array Normalized item array
     */
    public function normalize_item($raw_data);
    /**
     * Get rich data for templates
     * @param array $item Normalized item
     * @return array Rich data including template_data key
     */
    public function get_template_data($item);
    /**
     * Validate source settings
     * @param array $settings Settings to validate
     * @return bool|WP_Error True if valid, WP_Error if invalid
     */
    public function validate_settings($settings);
    /**
     * Should this source use caching?
     * @return bool
     */
    public function should_cache();
    /**
     * Get cache key for this source with filters
     * @param array $filters Filter criteria
     * @return string Cache key
     */
    public function get_cache_key($filters = []);
}

Normalized Data Contract

All sources must return items in this standardized format:

array(
    'id' => 'unique_identifier',           // Required: Unique ID within source
    'title' => 'Display Title',             // Required: Item title/name
    'excerpt' => 'Brief description',       // Optional: Short description
    'coordinates' => array(                 // Required: Geographic coordinates
        'lat' => 40.7128,
        'lng' => -74.0060
    ),
    'image' => 'image_url',                 // Optional: Primary image URL
    'url' => 'detail_page_url',            // Optional: Link to full details
    'source_id' => 'source_identifier',    // Required: Source registry ID
    'filters' => array(                     // Optional: Filterable attributes
        'category' => 'residential',
        'price' => 500000,
        'bedrooms' => 3
    ),
    'template_data' => $raw_source_data    // Required: Full original data
)

Example: Custom API Source

Here’s a complete example of a custom source that pulls data from an external API:

class MLS_API_Source implements LV_Source_Interface {
    private $settings;
    public function __construct($settings) {
        $this->settings = $settings;
    }
    public function get_items($filters = [], $limit = 50) {
        // Check cache first
        $cache_key = $this->get_cache_key($filters);
        $cached = get_transient($cache_key);
        if ($cached !== false) {
            return $cached;
        }
        // Fetch from API
        $api_url = 'https://api.example.com/properties';
        $response = wp_remote_get($api_url, array(
            'headers' => array(
                'Authorization' => 'Bearer ' . $this->settings['api_key']
            ),
            'timeout' => 15
        ));
        if (is_wp_error($response)) {
            return array();
        }
        $data = json_decode(wp_remote_retrieve_body($response), true);
        // Normalize each item
        $items = array();
        foreach ($data['properties'] as $property) {
            $normalized = $this->normalize_item($property);
            if ($normalized) {
                $items[] = $normalized;
            }
        }
        // Cache for 1 hour
        set_transient($cache_key, $items, HOUR_IN_SECONDS);
        return array_slice($items, 0, $limit);
    }
    public function get_item($id) {
        $items = $this->get_items();
        foreach ($items as $item) {
            if ($item['id'] === $id) {
                return $item;
            }
        }
        return null;
    }
    public function normalize_item($raw_data) {
        // Ensure required data exists
        if (!isset($raw_data['latitude']) || !isset($raw_data['longitude'])) {
            return null;
        }
        return array(
            'id' => $raw_data['mls_id'],
            'title' => $raw_data['address'],
            'excerpt' => $raw_data['description'],
            'coordinates' => array(
                'lat' => floatval($raw_data['latitude']),
                'lng' => floatval($raw_data['longitude'])
            ),
            'image' => $raw_data['primary_photo'],
            'url' => home_url('/property/' . $raw_data['mls_id']),
            'source_id' => $this->settings['source_id'],
            'filters' => array(
                'price' => intval($raw_data['price']),
                'bedrooms' => intval($raw_data['bedrooms']),
                'property_type' => $raw_data['type']
            ),
            'template_data' => $raw_data
        );
    }
    public function get_template_data($item) {
        return $item; // Already includes template_data
    }
    public function get_filters() {
        return array(
            'price' => array(
                'type' => 'range',
                'label' => 'Price',
                'min' => 0,
                'max' => 10000000,
                'step' => 50000
            ),
            'bedrooms' => array(
                'type' => 'select',
                'label' => 'Bedrooms',
                'options' => array(1, 2, 3, 4, 5, '6+')
            ),
            'property_type' => array(
                'type' => 'checkbox',
                'label' => 'Property Type',
                'options' => array('Single Family', 'Condo', 'Townhouse', 'Multi-Family')
            )
        );
    }
    public function validate_settings($settings) {
        if (empty($settings['api_key'])) {
            return new WP_Error('missing_api_key', 'API key is required');
        }
        return true;
    }
    public function should_cache() {
        return true; // External API should cache
    }
    public function get_cache_key($filters = []) {
        return 'lv_mls_' . md5(serialize($filters));
    }
}

Registering Custom Sources

Register your source using the lv_register_sources hook:

add_action('lv_register_sources', function($registry) {
    $registry->register('mls_api', array(
        'label' => 'MLS Property Feed',
        'class' => 'MLS_API_Source',
        'templates' => array(
            'grid' => 'mls-grid.php',
            'popup' => 'mls-popup.php'
        ),
        'filters' => array('bedrooms', 'price', 'property_type'),
        'cache_ttl' => 3600, // 1 hour
        'settings_fields' => array(
            'api_key' => array(
                'type' => 'text',
                'label' => 'API Key',
                'required' => true
            ),
            'region' => array(
                'type' => 'select',
                'label' => 'Region',
                'options' => array('east', 'west', 'central')
            )
        ),
        'icon' => 'fa-home',
        'color' => '#e74c3c'
    ));
});

Template Override System

JavaScript Template Manager

Location Viewer uses a client-side template system for rendering grid items and map popups. Templates are registered via the global LocationViewerTemplates object.

Template Hierarchy

Templates are resolved in this order (most specific to least specific):

  1. Instance-specific: grid-item-source_1234_5678 (source ID + instance ID)
  2. Source-type specific: grid-item-post_type_properties (source type + post type)
  3. Post-type specific: grid-item-properties (post type slug)
  4. Generic fallback: grid-item (default template)

Registering Templates

Register custom templates in your theme or plugin JavaScript:

// Wait for template manager to load
document.addEventListener('DOMContentLoaded', function() {
    // Generic grid item template
    LocationViewerTemplates.register('grid-item', function(item, sourceConfig) {
        return `
            <div class="lv-grid-item" data-item-id="${item.id}" data-source="${item.source_id}">
                <div class="lv-item-image">
                    ${item.image ? `<img src="${item.image}" alt="${item.title}">` : ''}
                </div>
                <h3>${item.title}</h3>
                <p>${item.excerpt || ''}</p>
                <a href="${item.url}" class="lv-view-details">View Details</a>
            </div>
        `;
    });
    // Source-specific template for properties
    LocationViewerTemplates.register('grid-item-properties', function(item, sourceConfig) {
        const data = item.template_data;
        return `
            <div class="lv-grid-item lv-property" data-item-id="${item.id}">
                <div class="property-image" style="background-image: url(${item.image})">
                    <span class="price">$${data.price.toLocaleString()}</span>
                </div>
                <div class="property-details">
                    <h3>${item.title}</h3>
                    <div class="property-meta">
                        <span>${data.bedrooms} bed</span>
                        <span>${data.bathrooms} bath</span>
                        <span>${data.sqft} sqft</span>
                    </div>
                    <p>${item.excerpt}</p>
                </div>
            </div>
        `;
    });
    // Map popup template
    LocationViewerTemplates.register('popup', function(item, sourceConfig) {
        return `
            <div class="lv-popup">
                <h4>${item.title}</h4>
                ${item.image ? `<img src="${item.image}" alt="${item.title}">` : ''}
                <p>${item.excerpt || ''}</p>
                ${item.url ? `<a href="${item.url}" style="color: ${sourceConfig.color}">View Details</a>` : ''}
            </div>
        `;
    });
});

Template Data Structure

Templates receive two arguments:

  1. item (object): Normalized item data with template_data containing full original data
  2. sourceConfig (object): Source configuration including icon, color, label
// Example item object
{
    id: "123",
    title: "Beautiful Home",
    excerpt: "3 bed, 2 bath property...",
    coordinates: { lat: 40.7128, lng: -74.0060 },
    image: "https://example.com/image.jpg",
    url: "https://example.com/property/123",
    source_id: "properties",
    filters: { bedrooms: 3, price: 500000 },
    template_data: {
        // Full original data from source
        price: 500000,
        bedrooms: 3,
        bathrooms: 2,
        sqft: 2000,
        // ... any other source-specific data
    }
}
// Example sourceConfig object
{
    id: "properties",
    label: "Properties",
    icon: "fa-home",
    color: "#3498db",
    enabled: true
}

Hooks and Filters

Actions

HookParametersDescription
lv_register_sources$registryRegister custom sources with the registry
lv_before_render_grid$items, $sourcesFires before grid items are rendered
lv_after_render_grid$items, $sourcesFires after grid items are rendered
lv_before_render_map$items, $sourcesFires before map is initialized
lv_after_render_map$items, $sourcesFires after map is initialized

Filters

HookParametersDescription
lv_source_items$items, $source_idFilter items before rendering
lv_normalized_item$item, $raw_data, $sourceFilter normalized item data
lv_template_data$data, $item, $sourceFilter template data before rendering
lv_map_config$config, $block_attrsFilter map configuration
lv_grid_classes$classes, $sourceFilter grid container CSS classes

Example: Filter Items by Distance

add_filter('lv_source_items', function($items, $source_id) {
    // Only filter properties
    if ($source_id !== 'properties') {
        return $items;
    }
    // Get user location (from cookie, IP geolocation, etc.)
    $user_lat = 40.7128;
    $user_lng = -74.0060;
    $max_distance = 25; // miles
    // Filter items by distance
    return array_filter($items, function($item) use ($user_lat, $user_lng, $max_distance) {
        $distance = calculate_distance(
            $user_lat, $user_lng,
            $item['coordinates']['lat'], $item['coordinates']['lng']
        );
        return $distance <= $max_distance;
    });
}, 10, 2);
function calculate_distance($lat1, $lon1, $lat2, $lon2) {
    $earth_radius = 3959; // miles
    $dLat = deg2rad($lat2 - $lat1);
    $dLon = deg2rad($lon2 - $lon1);
    $a = sin($dLat/2) * sin($dLat/2) +
         cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
         sin($dLon/2) * sin($dLon/2);
    $c = 2 * atan2(sqrt($a), sqrt(1-$a));
    return $earth_radius * $c;
}

AJAX API Reference

Get Location Data

Endpoint: location_viewer_ajax_get_data

// Request
jQuery.ajax({
    url: locationViewerData.ajaxUrl,
    method: 'POST',
    data: {
        action: 'location_viewer_ajax_get_data',
        nonce: locationViewerData.nonce,
        sources: ['properties', 'offices'],
        filters: {
            price_min: 200000,
            price_max: 500000,
            bedrooms: 3
        }
    },
    success: function(response) {
        if (response.success) {
            console.log(response.data.items);
            console.log(response.data.sources);
        }
    }
});
// Response
{
    success: true,
    data: {
        items: [
            {
                id: "123",
                title: "Property Title",
                // ... normalized item data
            }
        ],
        sources: {
            properties: {
                id: "properties",
                label: "Properties",
                icon: "fa-home",
                color: "#3498db"
            }
        }
    }
}

JavaScript API

LocationViewerTemplates

Global template management object.

// Register a template
LocationViewerTemplates.register(templateName, templateFunction);
// Render a template
const html = LocationViewerTemplates.render(baseType, item, sourceConfig);
// Check if template exists
if (LocationViewerTemplates.templates['grid-item-properties']) {
    // Template is registered
}

Events

Location Viewer fires custom JavaScript events you can listen for:

// View changed event
document.addEventListener('lv:viewChanged', function(e) {
    console.log('View changed to:', e.detail.view);
    // e.detail.view = 'split', 'map', or 'grid'
});
// Source toggled in legend
document.addEventListener('lv:sourceToggled', function(e) {
    console.log('Source toggled:', e.detail.sourceId, e.detail.visible);
});
// Item clicked in grid
document.addEventListener('lv:gridItemClicked', function(e) {
    console.log('Grid item clicked:', e.detail.item);
});
// Marker clicked on map
document.addEventListener('lv:markerClicked', function(e) {
    console.log('Marker clicked:', e.detail.item);
});

Code Examples

Example 1: JSON Feed Source

class JSON_Feed_Source implements LV_Source_Interface {
    private $feed_url;
    public function __construct($settings) {
        $this->feed_url = $settings['feed_url'];
    }
    public function get_items($filters = [], $limit = 50) {
        $response = wp_remote_get($this->feed_url);
        if (is_wp_error($response)) {
            return array();
        }
        $data = json_decode(wp_remote_retrieve_body($response), true);
        $items = array();
        foreach ($data as $raw_item) {
            $normalized = $this->normalize_item($raw_item);
            if ($normalized) {
                $items[] = $normalized;
            }
        }
        return array_slice($items, 0, $limit);
    }
    public function normalize_item($raw_data) {
        return array(
            'id' => $raw_data['id'],
            'title' => $raw_data['name'],
            'excerpt' => $raw_data['description'],
            'coordinates' => array(
                'lat' => floatval($raw_data['lat']),
                'lng' => floatval($raw_data['lng'])
            ),
            'image' => $raw_data['image_url'],
            'url' => $raw_data['link'],
            'source_id' => 'json_feed',
            'template_data' => $raw_data
        );
    }
    // Implement remaining interface methods...
}
// Register the source
add_action('lv_register_sources', function($registry) {
    $registry->register('json_feed', array(
        'label' => 'External JSON Feed',
        'class' => 'JSON_Feed_Source',
        'settings_fields' => array(
            'feed_url' => array(
                'type' => 'url',
                'label' => 'Feed URL'
            )
        ),
        'icon' => 'fa-rss',
        'color' => '#f39c12'
    ));
});

Example 2: Custom Grid Template with Ratings

LocationViewerTemplates.register('grid-item-restaurants', function(item, sourceConfig) {
    const data = item.template_data;
    const stars = '★'.repeat(data.rating) + '☆'.repeat(5 - data.rating);
    return `
        <div class="lv-grid-item restaurant-card" data-item-id="${item.id}">
            <div class="restaurant-header">
                <img src="${item.image}" alt="${item.title}">
                <div class="rating">${stars}</div>
            </div>
            <div class="restaurant-body">
                <h3>${item.title}</h3>
                <p class="cuisine">${data.cuisine}</p>
                <p class="price-range">${'$'.repeat(data.price_level)}</p>
                <p class="description">${item.excerpt}</p>
                <div class="restaurant-meta">
                    <span class="distance">${data.distance} mi away</span>
                    <span class="hours">${data.hours}</span>
                </div>
            </div>
            <div class="restaurant-footer">
                <a href="${item.url}" class="btn-details">View Menu</a>
                <a href="tel:${data.phone}" class="btn-call">Call Now</a>
            </div>
        </div>
    `;
});

Example 3: Filter Items by Custom Taxonomy

add_filter('lv_source_items', function($items, $source_id) {
    // Only apply to events source
    if ($source_id !== 'events') {
        return $items;
    }
    // Get selected category from query param
    $category = isset($_GET['event_category']) ? sanitize_text_field($_GET['event_category']) : '';
    if (empty($category)) {
        return $items;
    }
    // Filter items by category in template_data
    return array_filter($items, function($item) use ($category) {
        return isset($item['template_data']['category'])
            && $item['template_data']['category'] === $category;
    });
}, 10, 2);

Best Practices

  • Always validate coordinates: Ensure latitude is -90 to 90 and longitude is -180 to 180
  • Cache external sources: Use WordPress transients for API data with appropriate TTL
  • Escape output: Always escape HTML in templates to prevent XSS
  • Handle errors gracefully: Return empty arrays instead of throwing errors
  • Use WP_Error: Return WP_Error objects for validation failures
  • Enqueue dependencies: Ensure scripts/styles are properly enqueued with dependencies
  • Follow WordPress coding standards: Use WordPress coding standards for PHP and JavaScript
  • Prefix everything: Use lv_ prefix for functions, hooks, and database keys

Support and Contributing

For bug reports, feature requests, or contributions, please visit the plugin’s GitHub repository or support forum.

Scroll to Top