view gnv-artifacts/src/main/java/de/intevation/gnv/state/timeseries/TimeSeriesOutputState.java @ 436:6642ab6c583c

Added vectorizer rings callback which generates polygon datasets suitable for polygon plots. gnv-artifacts/trunk@484 c6561f87-3c4e-4783-a992-168aeb5c3f6f
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Sat, 26 Dec 2009 15:32:08 +0000
parents bed9735adf84
children 8975de9d7483
line wrap: on
line source
/**
 *
 */
package de.intevation.gnv.state.timeseries;

import java.io.IOException;
import java.io.File;
import java.io.OutputStream;
import java.io.FileOutputStream;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Vector;

import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.TransformerFactoryConfigurationError;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;

import org.apache.log4j.Logger;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import org.jfree.chart.ChartTheme;

import au.com.bytecode.opencsv.CSVWriter;
import de.intevation.artifactdatabase.Config;
import de.intevation.artifactdatabase.XMLUtils;
import de.intevation.artifacts.CallMeta;
import de.intevation.artifacts.CallContext;
import de.intevation.artifacts.PreferredLocale;
import de.intevation.gnv.artifacts.context.GNVArtifactContext;
import de.intevation.gnv.artifacts.context.GNVArtifactContextFactory;
import de.intevation.gnv.artifacts.ressource.RessourceFactory;
import de.intevation.gnv.chart.Chart;
import de.intevation.gnv.chart.ChartLabels;
import de.intevation.gnv.chart.TimeSeriesChart;
import de.intevation.gnv.chart.XMLChartTheme;
import de.intevation.gnv.chart.exception.TechnicalChartException;
import de.intevation.gnv.exports.ChartExportHelper;
import de.intevation.gnv.exports.DefaultExport;
import de.intevation.gnv.exports.DefaultDataCollector;
import de.intevation.gnv.exports.SimpleOdvDataCollector;
import de.intevation.gnv.exports.DefaultProfile;
import de.intevation.gnv.exports.Export.Profile;
import de.intevation.gnv.geobackend.base.Result;
import de.intevation.gnv.state.InputData;
import de.intevation.gnv.state.OutputStateBase;
import de.intevation.gnv.state.describedata.KeyValueDescibeData;
import de.intevation.gnv.state.describedata.NamedCollection;
import de.intevation.gnv.state.exception.StateException;
import de.intevation.gnv.statistics.Statistic;
import de.intevation.gnv.statistics.StatisticSet;
import de.intevation.gnv.statistics.Statistics;
import de.intevation.gnv.statistics.TimeseriesStatistics;
import de.intevation.gnv.statistics.exception.StatisticsException;
import de.intevation.gnv.timeseries.gap.DefaultTimeGap;
import de.intevation.gnv.timeseries.gap.TimeGap;
import de.intevation.gnv.utils.ArtifactXMLUtilities;


/**
 * @author Tim Englich <tim.englich@intevation.de>
 * 
 */
public class TimeSeriesOutputState extends OutputStateBase {

    protected static final boolean CACHE_CHART =
        Boolean.parseBoolean(System.getProperty("cache.chart", "false"));

    protected static final boolean PDF_FORMAT_LANDSCAPE =
        Boolean.parseBoolean(System.getProperty("export.pdf.landscape","true"));

    protected static final String[] IMG_EXPORT_FORMAT = {
        "PNG", "JPEG", "GIF"
    };

    /**
     * The UID of this Class
     */
    private static final long serialVersionUID = 4178407570503098858L;

    /**
     * the logger, used to log exceptions and additonaly information
     */
    private static Logger log = Logger
            .getLogger(TimeSeriesOutputState.class);

    private static List<TimeGap> timeGapDefinitions = null;

    protected String domainLable = "chart.timeseries.title.xaxis";

    protected String featureValuesName = "featureid";
    protected String parameterValuesName = "parameterid";
    protected String measuremenValueName = "measurementid";
    protected String dateValueName = "dateid";

    public static final String [] TIMESERIES_CSV_PROFILE_COLUMNS = {
        "XORDINATE",
        "YORDINATE",
        "GROUP1",
        "GROUP2",
        "GROUP3"
    };


    public static final String [] TIMESERIES_TIMESERIES_CSV_COLUMN_LABEL = {
        "Date/Time",
        "Value",
        "ParameterID",
        "MeasurementID",
        "TimeseriesID"
    };

    public static final String [] TIMESERIES_MESH_CSV_COLUMN_LABEL = {
        "Date/Time",
        "Value",
        "ParameterID",
        "FeatureID",
        "MeshID"
    };

    public static final String [] TIMESERIES_ODV_PROFILE_NAMES = {
          "CRUISE",
          "STATION",
          "TYPE",
          "SHAPE",
          "BOTDEPTH",
          "DEPTH",
          "TIMEVALUE",
          "DATAVALUE",
          "PARAMETER"
    };


    public static final String [] ODV_COLUMN_HEADER = {
        "Cruise",
        "Station",
        "Type",
        "Longitude [deegrees_east]",
        "Latitude [deegrees_north]",
        "Bot. Depth [m]",
        "Depth [m]",
        "Date/Time",
        "Value",
        "Parameterid"
    };

    /**
     * Profile for exporting data to odv
     * TODO Change TIMESERIES_PROFILE_NAMES, which belong to CSV exports
     */
    public static final Profile TIMESERIES_ODV_PROFILE =
        new DefaultProfile(
            ODV_COLUMN_HEADER,
            '\t',
            CSVWriter.NO_QUOTE_CHARACTER,
            CSVWriter.NO_ESCAPE_CHARACTER,
            "ODV",
            "ISO-8859-1");

    /**
     * Constructor
     */
    public TimeSeriesOutputState() {
        super();
    }

    /**
     * @see de.intevation.gnv.transition.OutputTransition#out(java.lang.String,
     *      java.util.Collection, java.io.OutputStream, java.lang.String,
     *      de.intevation.artifacts.CallMeta)
     */
    public void out(
        Document format,
        Collection<InputData> inputData,
        OutputStream outputStream,
        String uuid,
        CallContext callContext
    ) throws StateException
    {
        log.debug("TimeSeriesOutputTransition.out");

        String outputMode = Config.getStringXPath(
            format,
            "action/out/@name"
        );
        String mimeType   = Config.getStringXPath(
            format,
            "action/out/mime-type/@value"
        );

        CallMeta callMeta         = callContext.getMeta();

        try {
            if (outputMode.equalsIgnoreCase("chart")) {
                log.debug("Chart will be generated.");
                int chartWidth = 600;
                int chartHeight = 400;
                try {
                    if (inputData != null) {
                        Iterator<InputData> it = inputData.iterator();
                        while (it.hasNext()) {
                            InputData ip = it.next();
                            if (ip.getName().equalsIgnoreCase("width")) {
                                chartWidth = Integer.parseInt(ip.getValue());
                            } else if (ip.getName().equalsIgnoreCase("height")) {
                                chartHeight = Integer.parseInt(ip.getValue());
                            }
                        }
                    }
                } catch (NumberFormatException e) {
                    log.error(e, e);
                    throw new StateException(e);
                }

                PreferredLocale[] locales = callMeta.getLanguages();
                Locale[] serverLocales    =
                    RessourceFactory.getInstance().getLocales();
                Locale locale             =
                    callMeta.getPreferredLocale(serverLocales);

                log.debug(
                    "Best locale - regarding intersection of server and " +
                    "browser locales -  is " + locale.toString()
                );

                Collection parameters   = this.getCleanedParameters(uuid);
                Collection measurements = this.getMeasurements(uuid);
                Collection dates        = this.getDates(uuid);

                ChartLabels chartLables = new ChartLabels(
                    createChartTitle(locale, uuid),
                    createChartSubtitle(locale, uuid),
                    RessourceFactory.getInstance().getRessource(
                        locale,
                        domainLable,
                        domainLable
                    )
                );

                String exportFormat = getExportFormat(mimeType);

                // TODO Remove this and parse input data
                boolean linesVisible = true;
                boolean shapesVisible = true;

                this.createChart(
                    outputStream,
                    parameters,
                    measurements,
                    dates,
                    chartLables,
                    callContext,
                    uuid,
                    exportFormat,
                    locale,
                    chartWidth,
                    chartHeight,
                    linesVisible,
                    shapesVisible
                );
            }
            else if (outputMode.equalsIgnoreCase("pdf")) {
                log.debug("Output mode == pdf");

                Locale[] serverLocales    =
                    RessourceFactory.getInstance().getLocales();
                Locale locale             =
                    callMeta.getPreferredLocale(serverLocales);

                // TODO Remove this and parse input data
                boolean linesVisible = true;
                boolean shapesVisible = true;

                log.debug(
                    "Best locale - regarding intersection of server and " +
                    "browser locales -  is " + locale.toString()
                );

                createPDF(
                    outputStream,
                    getCleanedParameters(uuid),
                    getMeasurements(uuid),
                    getDates(uuid),
                    new ChartLabels(
                        createChartTitle(locale, uuid),
                        createChartSubtitle(locale, uuid),
                        RessourceFactory.getInstance().getRessource(
                            locale,
                            domainLable,
                            domainLable
                        )
                    ),
                    uuid,
                    "A4",
                    true,
                    linesVisible,
                    shapesVisible,
                    locale,
                    callContext
                );
            }
            else if (outputMode.equalsIgnoreCase("svg")) {
                log.debug("Output mode == svg");
                int width  = 600;
                int height = 400;

                // TODO Remove this and parse input data
                boolean linesVisible = true;
                boolean shapesVisible = true;

                Locale[] serverLocales    =
                    RessourceFactory.getInstance().getLocales();
                Locale locale             =
                    callMeta.getPreferredLocale(serverLocales);

                log.debug(
                    "Best locale - regarding intersection of server and " +
                    "browser locales -  is " + locale.toString()
                );

                createSVG(
                    outputStream,
                    getCleanedParameters(uuid),
                    getMeasurements(uuid),
                    getDates(uuid),
                    new ChartLabels(
                        createChartTitle(locale, uuid),
                        createChartSubtitle(locale, uuid),
                        RessourceFactory.getInstance().getRessource(
                            locale,
                            domainLable,
                            domainLable
                        )
                    ),
                    uuid,
                    locale,
                    width,
                    height,
                    linesVisible,
                    shapesVisible,
                    callContext
                );
            }
            else if (outputMode.equalsIgnoreCase("csv")) {
                log.debug("CSV-File will be generated.");
                Collection<Result> chartResult =
                    (Collection<Result>) getChartResult(uuid);
                this.createCSV(outputStream, chartResult);
            } else if (outputMode.equalsIgnoreCase("statistics")) {
                log.debug("Statistics will be generated.");
                Statistics s = getStatisticsGenerator();
                Collection<Result> chartResult =
                    (Collection<Result>) getChartResult(uuid);
                Collection<KeyValueDescibeData> parameters = 
                                                this.getParameters(uuid);
                Collection<KeyValueDescibeData> measurements = 
                                                this.getMeasurements(uuid);
                Collection<KeyValueDescibeData> dates = 
                                                this.getDates(uuid);
                Collection<StatisticSet> statistics = 
                                      s.calculateStatistics(chartResult,
                                                            parameters,
                                                            measurements,
                                                            dates);
                Document doc = this.writeStatistics2XML(statistics);
                this.writeDocument2OutputStream(doc, outputStream);
            } else if (outputMode.equalsIgnoreCase("odv")) {
                
                Collection<Result> odvResult = this.getODVResult(uuid);
                this.createODV(outputStream, odvResult);
            }
        } catch (IOException e) {
            log.error(e, e);
            throw new StateException(e);
        } catch (TechnicalChartException e) {
            log.error(e, e);
            throw new StateException(e);
        } catch (StatisticsException e) {
            log.error(e, e);
            throw new StateException(e);
        }
    }


    protected String getExportFormat(String mime) {
        for(int i = 0; i < IMG_EXPORT_FORMAT.length; i++) {
            if (mime.trim().toUpperCase().indexOf(IMG_EXPORT_FORMAT[i]) > 0)
                return IMG_EXPORT_FORMAT[i];
        }

        // no format found relating to mimeType, default export as PNG
        return IMG_EXPORT_FORMAT[0];
    }


    protected Collection getCleanedParameters(Collection parameters) {
        Iterator iter        = parameters.iterator();
        Collection parameter = new Vector(parameters);
        while (iter.hasNext()) {
            KeyValueDescibeData data = (KeyValueDescibeData)iter.next();
            if (!data.isSelected())
                parameter.remove(data);
        }

        return parameter;
    }


    protected Collection getCleanedParameters(String uuid) {
        return getCleanedParameters(getParameters(uuid));
    }


    protected void createCSV(OutputStream out, Collection<Result> results)
    throws UnsupportedEncodingException, IOException, StateException
    {
        Iterator iter = results.iterator();
        Result   res  = iter.hasNext() ? (Result) iter.next() : null;

        if (res == null)
            return;

        Profile profile = null;
        int     dataid  = res.getInteger("DATAID").intValue();

        // on meshes
        if (dataid == 2) {
            profile =  new DefaultProfile(
                TIMESERIES_MESH_CSV_COLUMN_LABEL,
                ',',
                '"',
                '"',
                "CSV",
                "ISO-8859-1");
        }

        // on timeseries
        else {
            profile =  new DefaultProfile(
                TIMESERIES_TIMESERIES_CSV_COLUMN_LABEL,
                ',',
                '"',
                '"',
                "CSV",
                "ISO-8859-1");
        }

        DefaultExport export = new DefaultExport(
            new DefaultDataCollector(TIMESERIES_CSV_PROFILE_COLUMNS));
        export.create(profile, out, results);
    }


    /**
     * TODO Result is not used at the moment. Change result with correct data.
     */
    protected void createODV(OutputStream outputStream, Collection result)
    throws IOException, StateException {

        DefaultExport export = new DefaultExport(new SimpleOdvDataCollector(
            TIMESERIES_ODV_PROFILE_NAMES));

        if (result == null)
            log.error("#################### RESULT == NULL #################");
        export.create(TIMESERIES_ODV_PROFILE, outputStream, result);
    }

    /**
     * @return
     */
    protected Statistics getStatisticsGenerator() {
        Statistics s = new TimeseriesStatistics();
        return s;
    }

    protected void writeDocument2OutputStream(Document document, OutputStream os) {

        try {
            TransformerFactory transformerFactory = TransformerFactory
                    .newInstance();
            Transformer transformer = transformerFactory.newTransformer();
            DOMSource source = new DOMSource(document);
            StreamResult result = new StreamResult(os);
            transformer.transform(source, result);
        } catch (TransformerConfigurationException e) {
            log.error(e, e);
        } catch (TransformerFactoryConfigurationError e) {
            log.error(e, e);
        } catch (TransformerException e) {
            log.error(e, e);
        }
    }

    protected Document writeStatistics2XML( Collection<StatisticSet> statistic) {
        ArtifactXMLUtilities xmlUtilities = new ArtifactXMLUtilities();
        Document doc = XMLUtils.newDocument();
        if (statistic != null) {
            Node statisticResults = xmlUtilities.createArtifactElement(doc,
                    "statistics");
            doc.appendChild(statisticResults);
            Iterator<StatisticSet> it = statistic.iterator();
            while (it.hasNext()) {
                StatisticSet set = it.next();
                Element setElement = xmlUtilities.createArtifactElement(doc,
                                                                       "statistic");
                setElement.setAttribute("name", set.getName());
                
                Iterator<Statistic> sit = set.getStatistics().iterator();
                while (sit.hasNext()){
                    Statistic s = sit.next();
                    Element result = xmlUtilities.createArtifactElement(doc,
                    "statistic-value");
                    result.setAttribute("name", s.getKey());
                    result.setAttribute("value", s.getStringValue());
                    setElement.appendChild(result);
                }
                statisticResults.appendChild(setElement);
            }

        }
        return doc;
    }


    protected String getSelectedFeatureName(String uuid) {
        Collection values = getCollection(featureValuesName, uuid);

        if (values != null) {
            Iterator it = values.iterator();

            while (it.hasNext()) {
                KeyValueDescibeData data = (KeyValueDescibeData) it.next();

                if (data.isSelected()) {
                    return data.getValue();
                }
            }
        }
        return null;
    }


    /**
     * @param outputStream
     * @param parameters
     * @param measurements
     * @param timeSeriesName
     * @param chartStyle
     * @param chartLables
     * @throws IOException
     * @throws TechnicalChartException
     */
    protected void createChart(
        OutputStream outputStream,
        Collection   parameters,
        Collection   measurements,
        Collection   dates,
        ChartLabels  chartLables,
        CallContext  context,
        String       uuid,
        String       exportFormat,
        Locale       locale,
        int          width,
        int          height,
        boolean      linesVisible,
        boolean      shapesVisible
    )
    throws IOException, TechnicalChartException
    {
        log.debug("Create chart.");
        Chart chart = getChart(
            chartLables,
            createStyle(context),
            parameters,
            measurements,
            dates,
            getChartResult(uuid),
            locale, // Locale
            uuid,
            linesVisible,
            shapesVisible
        );

        if (chart == null) {
            log.error("Could not initialize chart.");
            return;
        }

        log.debug(
            "export chart as " + exportFormat +
            " in " + width + "x" + height
        );

        ChartExportHelper.exportImage(
            outputStream,
            chart.generateChart(),
            exportFormat,
            width,
            height
        );
    }


    protected void createPDF(
        OutputStream outputStream,
        Collection   parameters,
        Collection   measurements,
        Collection   dates,
        ChartLabels  chartLables,
        String       uuid,
        String       exportFormat,
        boolean      landscape,
        boolean      linesVisible,
        boolean      shapesVisible,
        Locale       locale,
        CallContext  context
    ) {
        Chart chart = getChart(
            chartLables,
            createStyle(context),
            parameters,
            measurements,
            dates,
            (Collection) getChartResult(uuid),
            locale,
            uuid,
            linesVisible,
            shapesVisible
        );

        if (chart == null) {
            log.error("Could not initialize chart.");
            return;
        }

        ChartExportHelper.exportPDF(
            outputStream,
            chart.generateChart(),
            "A4",
            PDF_FORMAT_LANDSCAPE,
            50F, 50F, 50F, 50F
        );

        try {
            OutputStream toFile = new FileOutputStream("/vol1/home/iweinzierl/tmp/test.svg");
            ChartExportHelper.exportSVG(
                toFile,
                chart.generateChart(),
                null,
                600, 400
            );
            toFile.flush();
            toFile.close();
        }
        catch(Exception e) { log.debug("ERROR WHLILE TEST."); }
    }


    protected void createSVG(
        OutputStream outputStream,
        Collection   parameters,
        Collection   measurements,
        Collection   dates,
        ChartLabels  chartLables,
        String       uuid,
        Locale       locale,
        int          width,
        int          height,
        boolean      linesVisible,
        boolean      shapesVisible,
        CallContext  callContext
    ) {
        Chart chart = getChart(
            chartLables,
            createStyle(callContext),
            parameters,
            measurements,
            dates,
            (Collection) getChartResult(uuid),
            locale,
            uuid,
            linesVisible,
            shapesVisible
        );

        if (chart == null) {
            log.error("Could not initialize chart.");
            return;
        }

        ChartExportHelper.exportSVG(
            outputStream,
            chart.generateChart(),
            null,
            600, 400
        );

        log.debug("svg export finished.");
    }


    protected Chart getChart(
        ChartLabels  chartLables,
        ChartTheme   theme,
        Collection   parameters,
        Collection   measurements,
        Collection   dates,
        Object       result,
        Locale       locale,
        String       uuid,
        boolean      linesVisible,
        boolean      shapesVisible
    ) {
        Chart chart = null;

        if (CACHE_CHART) {
            log.info("Try to get timeseries chart from cache.");
            chart = (Chart) getChartFromCache(uuid);
        }

        if (chart != null)
            return chart;

        log.info("Chart not in cache yet.");
        chart = new TimeSeriesChart(
            chartLables,
            theme,
            parameters,
            measurements,
            dates,
            (Collection)result,
            timeGapDefinitions,
            locale,
            linesVisible,
            shapesVisible
        );
        chart.generateChart();

        if (CACHE_CHART) {
            log.info("Put chart into cache.");
            purifyChart(chart, uuid);
        }

        return chart;
    }

    protected ChartTheme createStyle(CallContext callContext) {
        log.debug("Fetch chart theme from global context");

        GNVArtifactContext context =
            (GNVArtifactContext) callContext.globalContext();

        XMLChartTheme theme = (XMLChartTheme) context.get(
            GNVArtifactContextFactory.CHARTTEMPLATE
        );

        return theme;
    }


    protected String createChartTitle(Locale locale, String uuid) {
        return getFisName(locale);

    }


    protected String createChartSubtitle(Locale locale, String uuid) {
        return getSelectedFeatureName(uuid);
    }


    protected String getFisName(Locale locale) {
        String    returnValue = "";
        InputData input       = inputData.get("fisname");

        if (input != null) {
            String value = input.getValue();

            returnValue = RessourceFactory.getInstance().getRessource(
                locale,
                value,
                value
            );
        }
        return returnValue;
    }


    protected Collection<KeyValueDescibeData> getParameters(String uuid) {
        return this.getCollection(parameterValuesName, uuid);
    }

    protected Collection<KeyValueDescibeData> getMeasurements(String uuid) {
        return this.getCollection(measuremenValueName, uuid);
    }
    protected Collection<KeyValueDescibeData> getDates(String uuid) {
        return this.getCollection(dateValueName,uuid);
    }

    @Override
    public void setup(Node configuration) {
        super.setup(configuration);
        String featureNameValue = Config.getStringXPath(configuration,
                "value-names/value-name[@name='feature']/@value");
        if (featureNameValue != null) {
            this.featureValuesName = featureNameValue;
        }
        String parameterNameValue = Config.getStringXPath(configuration,
                "value-names/value-name[@name='parameter']/@value");
        if (parameterNameValue != null) {
            this.parameterValuesName = parameterNameValue;
        }
        String measurementNameValue = Config.getStringXPath(configuration,
                "value-names/value-name[@name='measurement']/@value");
        if (measurementNameValue != null) {
            this.measuremenValueName = measurementNameValue;
        }
        
        String dateNameValue = Config.getStringXPath(configuration,
        "value-names/value-name[@name='date']/@value");
        if (dateNameValue != null) {
            this.dateValueName = dateNameValue;
        }
        if (timeGapDefinitions == null){
            Element gapDefinition =  (Element)Config.getNodeXPath(configuration,
                                                                 "time-gap-definition");
            synchronized (this.getClass()) {
                if (gapDefinition != null){
                    String link = gapDefinition.getAttribute("xlink:href");
                    if (link != null ){
                        String absolutFileName = Config.replaceConfigDir(link);
                        gapDefinition = (Element)new ArtifactXMLUtilities().
                                                     readConfiguration(absolutFileName);
                    }
                    
                    NodeList gapDefinitions = Config.getNodeSetXPath(gapDefinition, 
                                                                    "/time-gaps/time-gap");
                    if (gapDefinition != null){
                        timeGapDefinitions = new ArrayList<TimeGap>(gapDefinitions.
                                                                    getLength());
                        for (int i = 0; i < gapDefinitions.getLength(); i++){
                            Element gapNode = (Element)gapDefinitions.item(i);
                            String unit = gapNode.getAttribute("unit");
                            int key = Integer.parseInt(gapNode.getAttribute("key"));
                            int value = Integer.parseInt(gapNode.getAttribute("gap"));
                            log.info("Add new Timegap: "+key+" "+value+" "+ unit);
                            timeGapDefinitions.add(new DefaultTimeGap(unit, 
                                                                      key, 
                                                                      value));
                        }
                    }
                    
                }
            }
        }
    }
    
    /**
     * @param collectionName
     * @return
     */
    protected Collection<KeyValueDescibeData> getCollection(
                                                            String collectionName, 
                                                            String uuid) {
        Iterator<Object> it = this.getDescibeData(uuid).iterator();
        while (it.hasNext()) {

            Object o = it.next();

            if (o instanceof NamedCollection<?>) {
                NamedCollection<KeyValueDescibeData> nc = (NamedCollection<KeyValueDescibeData>) o;
                if (nc.getName().equals(collectionName)) {
                    return nc;
                }
            }
        }
        return null;
    }
}
// vim:set ts=4 sw=4 si et sta sts=4 fenc=utf-8 :

http://dive4elements.wald.intevation.org