Developer Guide
Comprehensive developer documentation for extending Location Viewer with custom sources, template overrides, and advanced integrations.
Table of Contents
- Plugin Architecture
- Creating Custom Sources
- Template Override System
- Hooks and Filters
- AJAX API Reference
- JavaScript API
- Code Examples
Plugin Architecture
Core Concepts
Location Viewer is built on three fundamental architectural patterns:
- Source Registry: Centralized registration system for location data sources
- Universal Interface: Consistent contract that all sources must implement
- 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):
- Instance-specific:
grid-item-source_1234_5678(source ID + instance ID) - Source-type specific:
grid-item-post_type_properties(source type + post type) - Post-type specific:
grid-item-properties(post type slug) - 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:
- item (object): Normalized item data with
template_datacontaining full original data - 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
| Hook | Parameters | Description |
|---|---|---|
lv_register_sources | $registry | Register custom sources with the registry |
lv_before_render_grid | $items, $sources | Fires before grid items are rendered |
lv_after_render_grid | $items, $sources | Fires after grid items are rendered |
lv_before_render_map | $items, $sources | Fires before map is initialized |
lv_after_render_map | $items, $sources | Fires after map is initialized |
Filters
| Hook | Parameters | Description |
|---|---|---|
lv_source_items | $items, $source_id | Filter items before rendering |
lv_normalized_item | $item, $raw_data, $source | Filter normalized item data |
lv_template_data | $data, $item, $source | Filter template data before rendering |
lv_map_config | $config, $block_attrs | Filter map configuration |
lv_grid_classes | $classes, $source | Filter 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.